Skip to content

feat!: replace bundled pnpm binary with npm + lockfile bootstrap#212

Merged
zkochan merged 15 commits intomasterfrom
next
Mar 21, 2026
Merged

feat!: replace bundled pnpm binary with npm + lockfile bootstrap#212
zkochan merged 15 commits intomasterfrom
next

Conversation

@zkochan
Copy link
Copy Markdown
Member

@zkochan zkochan commented Mar 16, 2026

Summary

  • Remove the 9MB bundled pnpm.cjs/worker.js binaries from git
  • Use npm ci with committed package-lock.json files (~5KB) to install a bootstrap pnpm with integrity verification
  • Bootstrap pnpm then installs the target version, verified via the project's pnpm-lock.yaml (using --lockfile-dir)
  • Switch bundler from @vercel/ncc to esbuild (faster, handles ESM + JSON natively)
  • Modernize to ESM ("type": "module", module: "ESNext", moduleResolution: "bundler")

Trust chain

committed package-lock.json (text, reviewable, ~5KB)
  → npm ci installs bootstrap pnpm (integrity verified)
    → bootstrap pnpm installs target (verified via project's pnpm-lock.yaml)

Test plan

  • Test with version input specifying a pnpm version
  • Test with packageManager field in package.json
  • Test with standalone: true (@pnpm/exe)
  • Test with custom .npmrc for private registry
  • Test on Linux, macOS, and Windows runners

🤖 Generated with Claude Code

zkochan and others added 15 commits March 16, 2026 01:52
Remove the 9MB bundled pnpm.cjs/worker.js and instead use npm ci with
committed package-lock.json files (~5KB) to install a bootstrap pnpm,
which then installs the target version with integrity verification via
the project's pnpm-lock.yaml.

Also switch from ncc to esbuild and modernize to ESM.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The @actions/* packages use CJS require() for Node.js builtins,
which fails with "Dynamic require of 'os' is not supported" when
bundled as ESM. Switch esbuild output to CJS format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Node.js treats dist/index.js as ESM due to "type": "module",
but the bundle uses CJS require() calls. Remove the field so
Node.js defaults to CJS for .js files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove packageManager from package.json to avoid version conflict
  when the action tests against itself (uses: ./)
- Use shell: true on Windows so spawn can find npm.cmd

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The bootstrap only needs regular pnpm to install the target package.
@pnpm/exe requires install scripts which we skip with --ignore-scripts.
Also regenerate pnpm-lock.yaml to match current package.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
--lockfile-dir pointing to GITHUB_WORKSPACE causes the bootstrap pnpm
to use the project's pnpm-lock.yaml (which tracks project deps, not
pnpm itself), corrupting the install. Revert to --no-lockfile for now.
Lockfile-based integrity verification can be added when pnpm v11 has
proper support for verifying the pnpm package itself.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use `node .../pnpm/bin/pnpm.cjs` to run the bootstrap pnpm, matching
the approach used by the old bundled pnpm.cjs. This avoids issues with
the .bin symlink on different platforms.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Bootstrap pnpm via npm ci (verified by lockfile)
- Use `pnpm self-update <version>` for explicit version
- Let pnpm handle packageManager field automatically
- Remove standalone/exe-specific install logic (pnpm handles this)
- Update tests to not run pnpm install against the action repo itself

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- When standalone=true, bootstrap with @pnpm/exe via npm ci
- When standalone=false, bootstrap with pnpm via npm ci
- Both use pnpm self-update to reach the target version
- Remove --ignore-scripts from npm ci so @pnpm/exe install scripts run
- Add standalone test back to CI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Log .bin directory contents after npm ci to understand why
pnpm binary is not found in subsequent CI steps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
npm ci sometimes doesn't create the .bin/pnpm symlink for
@pnpm/exe (observed on Linux CI). Manually create the symlink
if it's missing after npm ci completes.

This fixes the case where standalone=true with no explicit version
(relying on packageManager field) — pnpm self-update wouldn't run,
leaving .bin empty and pnpm not found on PATH.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
pnpm v11 moved global binaries from PNPM_HOME to PNPM_HOME/bin.
Add the new bin subdirectory to PATH so that pnpm's global bin
directory check passes. This is backwards compatible — the extra
PATH entry is harmless for older pnpm versions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
pnpm v9 requires the packages field in pnpm-workspace.yaml.
Without it, `pnpm --version` fails with "packages field missing or empty".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@zkochan zkochan marked this pull request as ready for review March 21, 2026 13:02
@zkochan zkochan merged commit 58e6119 into master Mar 21, 2026
28 of 29 checks passed
laat added a commit to laat/mor that referenced this pull request Apr 11, 2026
PR pnpm/action-setup#212 (merged 2026-03-21) replaced the bundled
pnpm.cjs with an "npm bootstrap then self-update" architecture, but
pnpm self-update doesn't actually replace the npm-installed bootstrap
binary in the action's temp dir, so the version input is silently
ignored. Tracked upstream as pnpm/action-setup#225.

Pin to 2e223e0, the commit immediately before #212, which still uses
the bundled-binary approach and honours the version input. This also
restores the pnpm store cache via actions/setup-node, which had been
dropped along with the action when we briefly switched to corepack.
laat added a commit to laat/mor that referenced this pull request Apr 11, 2026
PR pnpm/action-setup#212 (merged 2026-03-21) replaced the bundled
pnpm.cjs with an "npm bootstrap then self-update" architecture, but
pnpm self-update doesn't actually replace the npm-installed bootstrap
binary in the action's temp dir, so the version input is silently
ignored. Tracked upstream as pnpm/action-setup#225.

Pin CI to 2e223e0, the commit immediately before #212, matching the
pin already in place for the pages workflow.
Comment thread src/install-pnpm/run.ts
await writeFile(path.join(dest, 'package.json'), packageJson)
await writeFile(path.join(dest, 'package-lock.json'), JSON.stringify(lockfile))

const npmExitCode = await runCommand('npm', ['ci'], { cwd: dest })
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this is already marked as a breaking change, but I just want to point out that pnpm/action-setup@5 solved a challenging chicken-egg scenario. With v6 and this change, that solution is gone:

  • Use npm ci with committed package-lock.json files (~5KB) to install a bootstrap pnpm with integrity verification

Consumers inside a corporate GHE instance are likely to be using clean-slate GitHub actions runners without Node or NPM preinstalled. So npm ci is guaranteed to fail in that situation. We can't assume NPM exists on the runner before installing PNPM. PNPM needs to exist first so the package cache can be immediately configured

      - name: Install PNPM
        uses: actions/pnpm-action-setup@v5.0.0
      - name: Install Node.js
        uses: actions/setup-node@v6.3.0
        with:
          cache: pnpm # not possible unless PNPM is already installed
          node-version-file: .nvmrc

The only known workaround is to run actions/setup-node twice. Once before installing PNPM, then again afterwards to configure the cache. Highly inefficient.

FYI @Eynorey

Copy link
Copy Markdown

@benquarmby benquarmby Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately the "use actions/setup-node twice" workaround also does not work. Steps like the following result in a cryptic error. We wont be able to upgrade to v6 in its current state.

      - name: Install Node.js
        uses: actions/setup-node@v6.3.0
        with:
          node-version-file: .nvmrc
      - name: Install PNPM
        uses: actions/pnpm-action-setup@v6.0.0
      - name: Configure Dependency Cache
        uses: actions/setup-node@v6.3.0
        with:
          cache: pnpm

Error: /home/runner/setup-pnpm/node_modules/.bin/pnpm: error while loading shared libraries: libatomic.so.1: cannot open shared object file: No such file or directory

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This deserves it's own issue FYI, you should open one. I was wondering why our GHE Actions had different errors compared to the GitHub ones.

Copy link
Copy Markdown

@benquarmby benquarmby Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, I'll try to find some time to explain this properly and open an issue.

From some quick research, it should be possible to use the NPM binary that lives beside the Node runtime the action is already executing on (via using: node24). I'll do some testing and make a suggestion if it pans out:

Suggested change
const npmExitCode = await runCommand('npm', ['ci'], { cwd: dest })
const npmBinary = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const npmPath = path.join(path.dirname(process.execPath), npmBinary);
const npmExitCode = await runCommand(npmPath, ['ci'], { cwd: dest })

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.

3 participants