Skip to content

feat: install pnpm from the npm registry, verified against npm's signature - #24

Merged
zkochan merged 3 commits into
mainfrom
verify-registry-signature
Aug 9, 2026
Merged

feat: install pnpm from the npm registry, verified against npm's signature#24
zkochan merged 3 commits into
mainfrom
verify-registry-signature

Conversation

@zkochan

@zkochan zkochan commented Aug 8, 2026

Copy link
Copy Markdown
Member

Summary

The action verified the release archive against the digest GitHub publishes for it. GitHub serves both the asset and the digest, so whoever can replace one can replace the other — that catches a corrupted download, not a tampered one.

The npm registry carries the same executable, byte for byte, and npm signs <name>@<version>:<integrity> with a key get-pnpm pins. That signature can't be produced without npm's private key, and behind it sits the maintainer's approval of the staged publish. Every version this action installs now comes from the registry, and is refused unless the signature and the checksum both check out.

I verified the executables are identical across both sources before relying on it:

v12.0.0-rc.1 GitHub release npm platform package
linux-x64 f58dff16… f58dff16…
darwin-arm64 180f2c62… 180f2c62…
win32-x64 (pnpm.exe) 290aac61… 290aac61…

v11 was going to stay on the release asset, because the registry copy declares @reflink/reflink as a dependency where the tarball bundles it, and this action has no step that would install it. That reasoning did not survive testing — an A/B with the dependency present and absent clones either way, and the tarball's bundled copy carries no native binding at all — so v11 comes from the registry too.

That is the more important half: latest resolves to v11, so until now the common case was the one verified only by GitHub's digest.

Two things fall out of not touching the GitHub API at all

  • token is unused. It existed to lift the anonymous 60 requests/hour limit on the release lookup, which no longer happens. Kept as a deprecated input so workflows passing it keep working.
  • Versions published to npm without a GitHub release now install, instead of hitting the "Some prerelease versions are published to npm but not released as downloadable binaries" error this action used to raise.

With no source left that needs it, the release lookup, the asset naming, the sha256 check and the archive extraction are all gone — download.ts loses about 200 lines.

Testing

Every spec form, end to end against the real registry, each one placed and executed:

input resolves to placed
next-12 12.0.0-rc.1 dist, pn, pnpm, pnpx, pnx
latest 11.20.0 dist, pn, pnpm, pnpx, pnx
^11.0.0 11.20.0 dist, pn, pnpm, pnpx, pnx
11.20.0 11.20.0 dist, pn, pnpm, pnpx, pnx

The existing smoke matrix covers Ubuntu (x64 + arm), macOS and Windows.

It uses get-pnpm rather than its own copy

The verification first landed here as its own implementation, because the package that does the same thing wasn't published. It is now (get-pnpm@0.0.1, from pnpm/get.pnpm.io), so the second commit deletes ~140 lines and depends on it.

downloadPnpm verifies and places the executable and nothing else — it exists precisely so a caller that manages its own directory can use it without the global install and shell-rc editing that pnpm setup does. The action keeps what is genuinely its own: version resolution with semver ranges, the alias hardlinks, and PATH.

That also puts the pinned keys in one place. get.pnpm.io refreshes them for all three of its installers on a schedule, so a rotation reaches this action as a dependency bump rather than by someone noticing.

get-pnpm is excluded from minimumReleaseAge in pnpm-workspace.yaml — pnpm added that itself when I installed a package published minutes earlier. Worth a look if you'd rather wait for it to age instead.


Written by an agent (Claude Code, claude-opus-5).

The action checked the release archive against the `digest` GitHub
publishes for it. GitHub serves both the asset and the digest, so
whoever can replace one can replace the other: that catches a corrupted
download, not a tampered one.

From v12 the npm registry carries the same executable, byte for byte,
and npm signs `<name>@<version>:<integrity>` with a key that is pinned
here. That signature cannot be produced without npm's private key, and
behind it sits the maintainer's approval of the staged publish — so v12
and newer are now fetched from the registry and refused unless both the
signature and the checksum check out.

v11 keeps using the release asset and its digest. Its `dist/` bundles
dependencies that the registry copy declares instead, and this action
has no step that would install them.

Two things fall out of not touching the GitHub API for v12+:

- `token` stops mattering there. It exists to lift the anonymous 60
  requests/hour limit on the release lookup.
- Versions published to npm without a GitHub release install fine now,
  instead of failing the lookup this action warns about.

The pinned keys are the ones pnpm itself pins for `pnpm audit
signatures`.
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The installer now resolves pnpm v12+ from the npm registry through get-pnpm. Older GitHub resolutions retain archive downloads, SHA-256 verification, and extraction. Logs identify the selected source.

Changes

pnpm registry installation

Layer / File(s) Summary
Source-discriminated resolution
src/install-pnpm/download.ts
ResolvedPnpm now distinguishes registry and GitHub sources. pnpm v12+ uses registry resolutions. GitHub resolutions include explicit archive metadata.
Source-specific installation
src/install-pnpm/download.ts, src/install-pnpm/run.ts, package.json, pnpm-workspace.yaml
Registry installations use get-pnpm. GitHub installations retain archive handling and verification. Logs report the registry or resolved URL. The dependency and release-age exclusion are configured.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant run
  participant downloadPnpm
  participant getPnpm
  participant GitHubArchive
  run->>downloadPnpm: Resolve pnpm version
  alt pnpm v12+
    downloadPnpm->>getPnpm: Install registry package
    getPnpm-->>downloadPnpm: Return installed pnpm
  else Older pnpm
    downloadPnpm->>GitHubArchive: Download and verify archive
    GitHubArchive-->>downloadPnpm: Return extracted pnpm
  end
  downloadPnpm-->>run: Report selected source
Loading

Possibly related PRs

  • pnpm/setup#1: Updates the pnpm download and install flow in src/install-pnpm/run.ts.
  • pnpm/setup#11: Refines standalone pnpm v12 installation and registry handling.
  • pnpm/setup#12: Changes pnpm resolution and download behavior in download.ts and run.ts.

Poem

A bunny hops through registry doors,
While GitHub keeps its archives stored.
Checksums guard each bundled trail,
get-pnpm brings packages without fail.
Sources now show where downloads came—
Two paths, one installer, same pnpm name.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: installing pnpm from the npm registry with npm signature verification.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch verify-registry-signature

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Verify pnpm v12+ installs using npm registry signatures (fallback to GitHub for v11)

✨ Enhancement 🕐 20-40 Minutes

Grey Divider

AI Description

• Fetch pnpm v12+ from npm registry and verify its signed integrity metadata.
• Keep pnpm v11 on GitHub releases, verifying against the published sha256 digest.
• Download and assemble binary + dist/ from separate registry packages for v12+.
Diagram

graph TD
  A["run.ts (installer)"] --> B{"pnpm major >= 12?"}
  B -->|"yes"| C["resolveFromRegistry"] --> D["npm registry metadata"] --> E["verify-signature.ts"] --> F["downloadFromRegistry"] --> G["destDir layout"]
  B -->|"no"| H["fetchRelease"] --> I["GitHub Releases API"] --> J["download archive"] --> K["verifySha256"] --> L["extract archive"] --> G

  subgraph Legend
    direction LR
    _code["Code"] ~~~ _dec{"Decision"} ~~~ _ext[["External API"]]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use Sigstore/TUF-style provenance for binaries
  • ➕ Stronger supply-chain guarantees (provenance, transparency logs, key rotation story).
  • ➕ Can unify verification across GitHub and registry sources.
  • ➖ Not currently what pnpm/npm publishes for these artifacts; would require upstream ecosystem changes.
  • ➖ Significantly more implementation and operational complexity for the action.
2. Depend on get-pnpm for v12+ download/verification
  • ➕ Centralizes security logic in one maintained library.
  • ➕ Reduces custom crypto/parsing code in the action.
  • ➖ Not currently reusable per PR notes (get-pnpm install flow includes setup side effects).
  • ➖ Adds dependency surface area and release coordination between projects.
3. Keep GitHub-only downloads but harden verification
  • ➕ No behavior change in artifact source.
  • ➕ Avoids assembling install from multiple packages.
  • ➖ GitHub digest is co-hosted with the asset, so it mainly detects corruption not tampering.
  • ➖ Does not address npm-published versions lacking matching GitHub releases.

Recommendation: The chosen approach (registry for v12+ with pinned-key signature verification, GitHub fallback for v11) is a good security/compatibility tradeoff: it materially improves tamper resistance where upstream provides signed metadata, while preserving v11 behavior to avoid silently dropping bundled dependencies. Consider a small follow-up to accept multiple signatures (verify any valid) if npm starts emitting more than one.

Files changed (4) +209 / -17

Enhancement (4) +209 / -17
download.tsAdd registry-backed resolution and integrity verification for pnpm v12+ +129/-16

Add registry-backed resolution and integrity verification for pnpm v12+

• Extends pnpm resolution to choose the npm registry for v12+ and GitHub releases for v11. Implements downloading multiple registry tarballs (platform exe + pnpm dist/), verifies each against its published integrity, and installs by extracting specific entries into the destination layout.

src/install-pnpm/download.ts

npm-signing-keys.tsPin npm registry signing public keys +29/-0

Pin npm registry signing public keys

• Adds a generated list of npm registry signing keys mirrored from npm’s keys endpoint and aligned with pnpm’s own pinned key set. This enables rejecting packages signed by unknown or expired keys rather than weakening verification.

src/install-pnpm/npm-signing-keys.ts

run.tsLog registry vs GitHub download source +3/-1

Log registry vs GitHub download source

• Updates installer logging to reflect whether pnpm is being downloaded from the npm registry (v12+) or a GitHub release URL (v11). This improves transparency for reviewers and users troubleshooting installs.

src/install-pnpm/run.ts

verify-signature.tsVerify npm registry signatures for package integrity metadata +48/-0

Verify npm registry signatures for package integrity metadata

• Introduces signature verification for npm registry metadata using pinned public keys. Rejects unsigned packages, unexpected/expired keys, and invalid signatures over the canonical message '<name>@<version>:<integrity>'.

src/install-pnpm/verify-signature.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/install-pnpm/download.ts (1)

92-95: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Name the registry cutover major.

The function already uses MIN_SUPPORTED_MAJOR for the lower bound. Add a matching constant for the registry cutover, for example MIN_REGISTRY_MAJOR = 12, so the two version boundaries read the same way.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/install-pnpm/download.ts` around lines 92 - 95, Define a named registry
cutover constant, such as MIN_REGISTRY_MAJOR = 12, alongside
MIN_SUPPORTED_MAJOR, and update the semver.major(version) comparison in the
download selection logic to use it instead of the literal 12.
src/install-pnpm/verify-signature.ts (1)

28-37: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Select the signature that matches a pinned key.

The code checks only signatures[0]. dist.signatures is an array. If npm ever returns more than one entry and the pinned key is not first, the install fails even though a pinned key signed the package. Match by keyid across all entries instead.

♻️ Proposed refactor
-  const signature = opts.signatures?.[0]
-  if (!signature) {
+  const signatures = opts.signatures ?? []
+  if (signatures.length === 0) {
     throw new Error(`${pkg} carries no npm registry signature, so it cannot be verified.`)
   }
 
-  const key = NPM_SIGNING_KEYS.find(({ keyid }) => keyid === signature.keyid)
-  if (!key) {
-    throw new Error(`${pkg} is signed with an unexpected npm key (${signature.keyid}). `
+  const match = signatures
+    .map((signature) => ({ signature, key: NPM_SIGNING_KEYS.find(({ keyid }) => keyid === signature.keyid) }))
+    .find(({ key }) => key != null)
+  if (!match?.key) {
+    throw new Error(`${pkg} is signed with an unexpected npm key (${signatures.map((s) => s.keyid).join(', ')}). `
       + 'If npm has rotated its signing key, this action needs updating.')
   }
+  const { signature, key } = match
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/install-pnpm/verify-signature.ts` around lines 28 - 37, Update the
signature selection in the verification flow to search all entries in
opts.signatures for one whose keyid matches a pinned key in NPM_SIGNING_KEYS,
rather than assuming signatures[0] is valid. Preserve the existing errors for
packages with no signatures and for signatures that have no matching pinned key,
using the selected signature’s keyid in the latter message.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/install-pnpm/download.ts`:
- Around line 320-330: Update verifyIntegrity to report only pkg.name in the
checksum error, parse pkg.integrity by splitting at the first hyphen while
preserving the remainder as expected, and validate the algorithm against an
explicit supported-hash allowlist before calling createHash, raising a clear
error for unsupported algorithms.

---

Nitpick comments:
In `@src/install-pnpm/download.ts`:
- Around line 92-95: Define a named registry cutover constant, such as
MIN_REGISTRY_MAJOR = 12, alongside MIN_SUPPORTED_MAJOR, and update the
semver.major(version) comparison in the download selection logic to use it
instead of the literal 12.

In `@src/install-pnpm/verify-signature.ts`:
- Around line 28-37: Update the signature selection in the verification flow to
search all entries in opts.signatures for one whose keyid matches a pinned key
in NPM_SIGNING_KEYS, rather than assuming signatures[0] is valid. Preserve the
existing errors for packages with no signatures and for signatures that have no
matching pinned key, using the selected signature’s keyid in the latter message.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5d964069-c2ac-4acc-9d36-92be7d5c162e

📥 Commits

Reviewing files that changed from the base of the PR and between 4700d73 and 25f19d8.

⛔ Files ignored due to path filters (1)
  • dist/index.js is excluded by !**/dist/**
📒 Files selected for processing (4)
  • src/install-pnpm/download.ts
  • src/install-pnpm/npm-signing-keys.ts
  • src/install-pnpm/run.ts
  • src/install-pnpm/verify-signature.ts
📜 Review details
⏰ Context from checks skipped due to timeout. (2)
  • GitHub Check: Analyze (actions)
  • GitHub Check: Analyze (javascript-typescript)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2026-05-11T16:19:49.450Z
Learnt from: zkochan
Repo: pnpm/setup PR: 1
File: src/cache-restore/run.ts:35-35
Timestamp: 2026-05-11T16:19:49.450Z
Learning: When using `actions/exec` (`getExecOutput` / `exec`), it is valid for the `commandLine` option to include both the command and its arguments in a single string (e.g., `getExecOutput('pnpm store path --silent')`). The library tokenizes `commandLine` internally (via `argStringToArray()`), so this behaves like passing an equivalent command + args array (e.g., `getExecOutput('pnpm', ['store','path','--silent'])`). In code reviews, do not flag this as incorrect—this matches documented behavior and a production-tested pattern.

Applied to files:

  • src/install-pnpm/run.ts
  • src/install-pnpm/download.ts
  • src/install-pnpm/npm-signing-keys.ts
  • src/install-pnpm/verify-signature.ts
🪛 Betterleaks (1.7.3)
src/install-pnpm/npm-signing-keys.ts

[high] 14-14: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)


[high] 21-21: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)

🔇 Additional comments (8)
src/install-pnpm/npm-signing-keys.ts (1)

1-29: LGTM!

src/install-pnpm/download.ts (5)

5-10: LGTM!

Also applies to: 24-63


109-116: LGTM!


158-170: LGTM!


291-318: LGTM!


123-144: 🗄️ Data Integrity & Integration

No change needed for platform package resolution.

The platform package names and the lifted tarball entries match the keep values.

src/install-pnpm/run.ts (1)

21-23: LGTM!

src/install-pnpm/verify-signature.ts (1)

43-47: 🔒 Security & Privacy

No change needed.

Line 44 builds the SPKI PEM body as a single unwrapped base64 line, and createVerify().verify() accepts it with the DER ECDSA sig input passed as base64.

Comment thread src/install-pnpm/download.ts Outdated
The verification landed here as its own implementation — a pinned key
list, a signature check, a registry download — because the package that
does the same thing was not published yet. It is now, so this drops ~140
lines and depends on it.

`downloadPnpm` from get-pnpm resolves the platform package, checks npm's
signature over its checksum against the pinned key, checks the download
against that checksum, and places the executable beside the `dist/` tree
it loads. The action keeps what is its own: version resolution with
semver ranges, the alias hardlinks, and PATH.

That also puts the pinned keys in one place. get.pnpm.io refreshes them
for all three of its installers on a schedule, so a rotation reaches
this action through a dependency bump rather than through someone
noticing.

v11 still comes from the GitHub release: its `dist/` bundles
dependencies that the registry copy declares instead, and this action
has no step that would install them.

get-pnpm is excluded from minimumReleaseAge, as pnpm asked when adding a
package published minutes earlier.
@zkochan

zkochan commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

Correction. This PR says v11 stays on the GitHub asset because "switching v11 would silently drop @reflink/reflink and with it copy-on-write cloning". I tested that claim properly and it is wrong.

A controlled A/B — same filesystem, same pnpm version, only the dependency manifest suppressed — clones either way:

v11 install pnpm add is-odd reports
with @reflink/reflink installed Packages are cloned from the content-addressable store
without it Packages are cloned from the content-addressable store

The release tarball agrees: its bundled @reflink ships binding.js, index.js and package.json with no native .node binding at all, and install.sh installs clone regardless.

So the constraint I built this around does not exist, and v11 could be fetched from the registry here too. That is worth doing rather than leaving: latest resolves to v11 today, so the common case in this action is still verified only by the GitHub digest, which is served by the same host as the asset it describes.

I have not changed it in this PR, because the remaining differences between the two v11 sources (undici/types, exponential-backoff/src, the @reflink JS shim) are ones I can only observe on Linux, and flipping the default path for the majority of users deserves its own change with the smoke matrix behind it. Happy to do that next if you want it.

v11 was left on the GitHub release asset because the registry copy
declares `@reflink/reflink` as a dependency where the release tarball
bundles it, and this action has no step that would install it. That
reasoning does not survive testing: an A/B with the dependency present
and absent clones either way, and the tarball's bundled copy has no
native binding at all.

So every version this action installs now comes from the registry, and
every one of them is checked against npm's signature. That matters more
for v11 than for v12: `latest` resolves to v11, so until now the common
case was verified only by GitHub's digest — served by the same host as
the asset it describes.

With no source left that needs it, the GitHub release lookup goes, and
with it the asset naming, the sha256 check, the archive extraction, and
the failure mode where a version published to npm without a release
could not be installed at all.

`token` existed to lift the anonymous rate limit on that lookup. It is
now unused, kept as a deprecated input so workflows that pass it keep
working.

Up to v11 get-pnpm writes a manifest for `pnpm setup` to install the
wrapper's dependencies from. This action never runs setup, so the
manifest is removed rather than left as a stray project file in a
directory that ends up on PATH — matching what the release tarball,
which has no manifest, produced here before.
@zkochan zkochan changed the title feat: verify pnpm 12 downloads against npm's registry signature feat: install pnpm from the npm registry, verified against npm's signature Aug 9, 2026
@zkochan
zkochan merged commit ed0c46d into main Aug 9, 2026
27 checks passed
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