Skip to content

image(pnpm): add pnpm image with pnpm-managed Node.js - #405

Draft
nozaq wants to merge 1 commit into
mainfrom
claude/add-pnpm-image-fxwqx7
Draft

image(pnpm): add pnpm image with pnpm-managed Node.js#405
nozaq wants to merge 1 commit into
mainfrom
claude/add-pnpm-image-fxwqx7

Conversation

@nozaq

@nozaq nozaq commented Sep 4, 2026

Copy link
Copy Markdown
Member

Why

pnpm 12 (stable since v12.0.0) manages Node.js itself. pnpm runtime set node <ver> -g installs a global runtime, and its project-aware shims make a bare node inside a project run the version that project pins through devEngines.runtime (onFail: download fetches it on first use). That makes pnpm a version manager in its own right, so it gets its own image alongside node rather than being layered onto it — the shape mise, rustup, and uv already use: the tool is in the image, the runtime comes from the project.

Two variants, 12-trixie and 12-bookworm. pnpm 11 is not built: it has pnpm runtime but no project-aware shims, which is the feature this image exists for.

Layout

pnpm is installed the way pnpm's own ghcr.io/pnpm/pnpm image installs it: the release archive unpacked into /opt/pnpm (the pnpm binary with dist/ next to it, which is where pnpm resolves node-gyp from), with a /usr/local/bin/pnpm symlink and a version assertion.

pnpm setup is deliberately not used. It is a per-user installer, and pointing it at a root-owned directory leaves a PNPM_HOME that global installs cannot write to (ERR_PNPM_PNPM_DIR_NOT_WRITABLE). PNPM_HOME is therefore the user's own ~/.local/share/pnpm, pnpm's default on Linux, with its bin/ on PATH — pnpm refuses a global install when it is not.

Supply chain

pnpm publishes no detached signature and no checksum on a host separate from the release, so a downloaded checksum would only prove the release host served its own artifact consistently. The archive is verified against its build provenance attestation instead: gh attestation verify checks that the digest was signed by pnpm's release workflow (pnpm/pnpm/.github/workflows/release.yml) at the tag being installed, against a Sigstore trusted root committed to this repository and kept in sync by update-material.yml.

gh is installed in the throwaway builder stage from GitHub's apt repository, with the keyring committed as trust material; Debian's own gh (2.46) predates the attestation command. Its SHA256 and both fingerprints match the values published in cli/cli's docs/install_linux.md.

--deny-self-hosted-runners is not passed. pnpm builds its release binaries on Blacksmith runners, which the OIDC claim reports as self-hosted, so the flag would reject every pnpm release rather than catch anything. Worth a reviewer's eye.

Workflow change

The attestation bundle is fetched from the GitHub API, where anonymous requests are capped at 60 per hour per IP, so build-checks.yml and publish-image.yml pass GITHUB_TOKEN to the build as a BuildKit secret. It is mounted for one instruction and never reaches a layer, the SBOM, or the provenance. A build without the secret still works.

Verification

Run here:

  • hadolint --config .hadolint.yaml over every Dockerfile, and shellcheck pnpm/smoke-test.sh: both clean.
  • The full pnpm/smoke-test.sh against a real pnpm 12.3.1 binary in a scratch HOME: passes. It covers the global runtime install, a project pinning a different major through devEngines.runtime (bare node gives v24 inside the project, v22 outside), pnpm add of a locally authored package, and pnpm exec.
  • The Dockerfile's download-and-verify chain, replayed step by step outside Docker: the attestation verifies, and a tampered archive, a wrong --source-ref, and a wrong --signer-workflow each fail it.

Not run here: no Docker daemon in this environment, and cli.github.com and the pnpm/pnpm API are blocked from it, so the image itself has never been built. CI is the first real build — the apt step, the attestation fetch, and the Trivy scan of dist/node_modules are all unproven until it runs.

Follow-up: a Dev Container template for pnpm in bare-devcontainer/templates, which the image README already links to.


Generated by Claude Code

pnpm 12 manages Node.js itself: `pnpm runtime set node <ver> -g` installs a
global runtime, and its project-aware shims make a bare `node` inside a project
run the version that project pins through `devEngines.runtime`. That makes pnpm
a version manager in its own right, so it gets its own image alongside `node`
rather than being layered onto it, in the shape the `mise`, `rustup`, and `uv`
images already use: the tool is in the image, the runtime comes from the
project.

pnpm is installed the way pnpm's own `ghcr.io/pnpm/pnpm` image installs it —
the release archive unpacked into /opt/pnpm with a /usr/local/bin/pnpm symlink
— rather than through `pnpm setup`, which is a per-user installer and needs a
writable PNPM_HOME. PNPM_HOME stays with the user at its default location, as
pnpm refuses a global install when it is not writable.

pnpm publishes no detached signature and no checksum on a host separate from
the release, so the archive is verified against its build provenance
attestation instead: `gh attestation verify` checks that the digest was signed
by pnpm's release workflow at the tag being installed, against a Sigstore
trusted root committed here. `gh` comes from GitHub's apt repository in the
throwaway builder stage, since Debian's own package predates the command.

The workflows pass a token to the build as a secret because the attestation is
fetched from the GitHub API, where anonymous requests are capped per IP.

nozaq commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

The three things the description lists as unproven have now run on c14457a, and all of them pass. Every check on the head commit is green.

  • The image builds. check-image and the build-image dry run both pass for 12-trixie and 12-bookworm on amd64 and arm64, so the gh install from the apt repository works on both architectures and both Debian releases.
  • The attestation is fetched and verified during the build. The build fails closed if the API returns no bundle or if verification rejects it, so a successful build is the evidence that the provenance for pnpm-linux-<arch>.tar.gz verified against the committed trusted root, with the signer workflow and refs/tags/v12.3.1 enforced.
  • Trivy is clean. No CRITICAL or HIGH finding in /opt/pnpm/dist/node_modules, so no .trivyignore.yaml entry is needed.

The smoke test also ran inside the built image, which exercises pnpm runtime set node 22 -g, the devEngines.runtime download of Node.js 24 inside a project, and the shim dispatch between them.

The one judgement call still worth a reviewer's eye is the omitted --deny-self-hosted-runners, explained in the description and in a comment in the Dockerfile.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant