Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions npm-shrinkwrap.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
120 changes: 120 additions & 0 deletions scripts/lib/materialize-sharp-release-runtime.ts
Original file line number Diff line number Diff line change
@@ -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<string, readonly string[]> = {
"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<string, LockedReleasePackage>
}

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<void> {
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<LockedReleasePackage>): Promise<void> {
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<Pick<LockedReleasePackage, "version" | "integrity">>,
tarball: string,
): Promise<void> {
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<void> {
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}`)
}
}
7 changes: 7 additions & 0 deletions scripts/package-release-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "..")
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 })
Expand Down
12 changes: 9 additions & 3 deletions tests/release-package-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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,
})
Expand Down
104 changes: 104 additions & 0 deletions tests/sharp-release-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, { version?: string; resolved?: string; integrity?: string }>
}
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<string, string> }
const playgroundPackage = JSON.parse(await readFile(join(repositoryRoot, "packages", "runtime-playground", "package.json"), "utf8")) as { dependencies: Record<string, string> }
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 })
}
})
Loading