diff --git a/tools/pack/src/linux.ts b/tools/pack/src/linux.ts index 1eccc12dee..9a4384ba43 100644 --- a/tools/pack/src/linux.ts +++ b/tools/pack/src/linux.ts @@ -96,14 +96,22 @@ export function buildDockerArgs( // None of these values can contain shell metacharacters, so direct // interpolation into the inner command string is safe. // - // We invoke pnpm via `corepack pnpm` instead of `corepack enable && pnpm ...` - // because the container runs as the host's non-root uid (--user above) and - // `corepack enable` writes shims next to the system Node binary, which is - // owned by root in electronuserland/builder:base and not writable by the - // unprivileged container user. `corepack pnpm` resolves and executes the - // pinned packageManager from package.json directly, no shims required. + // We can't rely on `corepack pnpm` here: although Node 16.10+ ships corepack, + // the `electronuserland/builder:base` image strips the corepack binary, so + // the inner `bash -lc` fails with `corepack: command not found`. We also + // can't `corepack enable` ourselves — the container runs as the host's + // non-root uid (--user above) and corepack would try to write shims next + // to the system Node binary, which is owned by root in this image. + // + // Use `npx --yes pnpm@` instead: `npx` ships with npm (always + // present in the image), `--yes` skips the install confirmation, and the + // package gets cached under `$HOME/.npm/_npx`, which is writable by the + // unprivileged user. The pinned version matches the `packageManager` + // field in the root package.json so reproducibility is preserved. + const PNPM_VERSION = "10.33.2"; + const pnpmCmd = `npx --yes pnpm@${PNPM_VERSION}`; const innerArgs = [ - "corepack pnpm tools-pack linux build", + `${pnpmCmd} tools-pack linux build`, `--to ${config.to}`, `--namespace ${config.namespace}`, "--dir /tools-pack", @@ -111,7 +119,7 @@ export function buildDockerArgs( if (config.portable) { innerArgs.push("--portable"); } - const innerCommand = "corepack pnpm install --frozen-lockfile && " + innerArgs.join(" "); + const innerCommand = `${pnpmCmd} install --frozen-lockfile && ` + innerArgs.join(" "); return [ "run", diff --git a/tools/pack/tests/linux.test.ts b/tools/pack/tests/linux.test.ts index 2b017a02e7..6b95bf6c2c 100644 --- a/tools/pack/tests/linux.test.ts +++ b/tools/pack/tests/linux.test.ts @@ -1,3 +1,7 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + import { describe, expect, it } from "vitest"; import type { ToolPackConfig } from "../src/config.js"; @@ -83,16 +87,33 @@ describe("buildDockerArgs", () => { it("re-invokes pnpm tools-pack linux build inside the container without --containerized", () => { const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 }); const last = args[args.length - 1]; - expect(last).toMatch(/corepack pnpm install --frozen-lockfile/); - expect(last).toMatch(/corepack pnpm tools-pack linux build --to all --namespace default/); + expect(last).toMatch(/npx --yes pnpm@\d+\.\d+\.\d+ install --frozen-lockfile/); + expect(last).toMatch(/npx --yes pnpm@\d+\.\d+\.\d+ tools-pack linux build --to all --namespace default/); expect(last).not.toMatch(/--containerized/); }); - it("invokes pnpm via `corepack pnpm` rather than `corepack enable` (non-root container can't write Node shim dir)", () => { + it("invokes pnpm via `npx --yes pnpm@` (electronuserland/builder:base strips corepack, and the non-root container can't write Node shim dir)", () => { const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 }); const last = args[args.length - 1]; - expect(last).not.toMatch(/corepack enable/); - expect(last).toMatch(/corepack pnpm/); + expect(last).not.toMatch(/corepack/); + expect(last).toMatch(/npx --yes pnpm@/); + }); + + it("hardcoded pnpm version stays in lockstep with root package.json `packageManager`", () => { + // Guard against silent drift: if someone bumps packageManager in the + // root package.json but forgets to update PNPM_VERSION in linux.ts, + // the Linux container build would silently keep using the old pnpm. + const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); + const rootPkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf-8")) as { + packageManager?: string; + }; + const match = String(rootPkg.packageManager ?? "").match(/^pnpm@(\d+\.\d+\.\d+)$/); + expect(match, `expected root packageManager "pnpm@x.y.z", got ${rootPkg.packageManager}`).not.toBeNull(); + const expectedVersion = match![1]; + + const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 }); + const last = args[args.length - 1]; + expect(last).toContain(`npx --yes pnpm@${expectedVersion}`); }); it("forwards --dir /tools-pack so inner build output lands under the mounted host dir", () => {