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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Install pnpm **and** a JavaScript runtime (Node.js, Bun, or Deno) in a single GitHub Actions step.

pnpm ships a self-contained release binary — the action downloads it for the runner's platform directly from pnpm's GitHub releases (no Node.js or npm needed) and then uses `pnpm runtime set` to install the requested runtime. The runtime binary is placed on `PATH` for subsequent steps, replacing the need for `actions/setup-node`, `oven-sh/setup-bun`, or `denoland/setup-deno`. `pnpm install` runs automatically when a `package.json` is present.
pnpm ships a self-contained release binary — the action downloads it for the runner's platform from the npm registry, refusing anything whose npm signature or checksum does not check out (no Node.js or npm needed) and then uses `pnpm runtime set` to install the requested runtime. The runtime binary is placed on `PATH` for subsequent steps, replacing the need for `actions/setup-node`, `oven-sh/setup-bun`, or `denoland/setup-deno`. `pnpm install` runs automatically when a `package.json` is present.

> [!NOTE]
> `pnpm/setup@v2` installs pnpm v11 and newer only — it relies on pnpm's self-contained release binaries and the `pnpm runtime` command, both available from v11. `v1` installed pnpm through npm and could set up pnpm 10; if you need pnpm 10 or older, use [`pnpm/action-setup`](https://github.com/pnpm/action-setup) instead.
Expand All @@ -22,7 +22,7 @@ If your `package.json` declares `devEngines.runtime`, the action picks up the ru
| `cache-dependency-path` | Path(s) to the pnpm lockfile, used to compute the cache key. Default: `pnpm-lock.yaml`. |
| `package-json-file` | Path to `package.json` (relative to `GITHUB_WORKSPACE`). Default: `package.json`. |
| `install` | Run `pnpm install` after setup. Default: `true`. Set to `false` for jobs that only need pnpm itself (e.g. `pnpm audit`, lockfile-only regeneration). |
| `token` | GitHub token used to look up the pnpm release and its asset checksum via the GitHub API. Defaults to `${{ github.token }}`, which lifts the low anonymous rate limit. Rarely needs to be set. |
| `token` | No longer used. pnpm is fetched from the npm registry and verified against npm's signature, so the action makes no GitHub API request. Kept so workflows that pass it keep working. |

## Outputs

Expand Down
11 changes: 6 additions & 5 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,13 @@ inputs:
required: false
default: 'true'
token:
description: |
GitHub token used to look up the pnpm release (and its asset checksum)
via the GitHub API. Defaults to the workflow's automatic token, which
lifts the low anonymous API rate limit. Rarely needs to be set.
description: >
No longer used. pnpm is fetched from the npm registry and verified
against npm's signature, so no GitHub API request is made. Kept so
workflows that pass it keep working.
required: false
default: ${{ github.token }}
deprecationMessage: 'The token input is no longer used; pnpm is fetched from the npm registry.'

outputs:
dest:
description: Expanded path of inputs#dest
Expand Down
330 changes: 170 additions & 160 deletions dist/index.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@types/node": "^22.0.0",
"@types/semver": "^7.5.8",
"expand-tilde": "^2.0.2",
"get-pnpm": "^0.0.1",
"semver": "^7.6.3",
"yaml": "^2.3.4"
},
Expand Down
10 changes: 10 additions & 0 deletions pnpm-lock.yaml

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

2 changes: 2 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ packages:
- '.'
allowBuilds:
esbuild: true
minimumReleaseAgeExclude:
- get-pnpm@0.0.1
191 changes: 32 additions & 159 deletions src/install-pnpm/download.ts
Original file line number Diff line number Diff line change
@@ -1,102 +1,65 @@
import { HttpClient } from '@actions/http-client'
import { spawn } from 'child_process'
import { createHash } from 'crypto'
import { createReadStream, createWriteStream, existsSync } from 'fs'
import { chmod, copyFile, link, mkdir, rm } from 'fs/promises'
import path from 'path'
import { pipeline } from 'stream/promises'
import semver from 'semver'
import { downloadPnpm as downloadVerifiedPnpm } from 'get-pnpm'

// The action downloads pnpm's self-contained release archive and uses
// `pnpm runtime` to install a JavaScript runtime. Both are available from v11
// onward, so that is the oldest major this action can install.
// The action installs pnpm's self-contained executable and uses `pnpm runtime`
// to install a JavaScript runtime. Both are available from v11 onward, so that
// is the oldest major this action can install.
const MIN_SUPPORTED_MAJOR = 11

const REGISTRY = 'https://registry.npmjs.org'
// Abbreviated packuments are much smaller than full ones and still carry the
// per-version metadata needed to resolve a version spec.
const ABBREVIATED_PACKUMENT = 'application/vnd.npm.install-v1+json'

const GITHUB_API = 'https://api.github.com'

export interface ResolvedPnpm {
readonly version: string
readonly downloadUrl: string
// Hex-encoded SHA-256 of the release archive, from the GitHub asset `digest`.
readonly sha256: string
readonly archive: 'tar.gz' | 'zip'
}

interface AbbreviatedPackument {
readonly versions: Record<string, unknown>
}

interface GitHubAsset {
readonly name: string
readonly digest?: string
readonly browser_download_url: string
}

interface GitHubRelease {
readonly assets: readonly GitHubAsset[]
}

// HTTPS_PROXY/NO_PROXY are honored automatically by @actions/http-client.
const http = new HttpClient('pnpm/setup', undefined, { allowRetries: true, maxRetries: 3 })

export async function resolvePnpm(spec: string, token?: string): Promise<ResolvedPnpm> {
/**
* A resolved version, fetched from the npm registry.
*
* The registry packages hold the same executable as the GitHub release assets,
* byte for byte, and npm signs a checksum for them with a key `get-pnpm` pins —
* so a tampered download cannot pass. GitHub publishes a digest, but serves it
* from the same place as the asset it describes, which catches corruption
* rather than tampering.
*/
export interface ResolvedPnpm {
readonly version: string
}

export async function resolvePnpm(spec: string): Promise<ResolvedPnpm> {
const version = await resolveVersion(spec)
if (semver.major(version) < MIN_SUPPORTED_MAJOR) {
throw new Error(`The requested pnpm version "${spec}" resolved to ${version}, but this action only installs pnpm v${MIN_SUPPORTED_MAJOR} or newer.
This action downloads pnpm's self-contained release binary and uses \`pnpm runtime\` to install a JavaScript runtime; both are available from v${MIN_SUPPORTED_MAJOR} onward.
To install older pnpm, use the pnpm/action-setup action instead.`)
}

const platform = getPlatform()
const asset = assetName(platform)
const release = await fetchRelease(version, token)
const found = release.assets.find((a) => a.name === asset)
if (!found) {
const isIntelMac = semver.major(version) === 11 && platform.os === 'darwin' && platform.arch === 'x64'
throw new Error(`pnpm ${version} has no ${asset} release asset for your platform. `
+ (isIntelMac
? 'pnpm v11 ships no binary for Intel macOS (darwin-x64); use v12 or newer there.'
: `See https://github.com/pnpm/pnpm/releases/tag/v${version} for the available assets.`))
}
if (!found.digest?.startsWith('sha256:')) {
throw new Error(`Release asset ${asset} for pnpm ${version} has no sha256 digest (got ${found.digest ?? '<missing>'}).`)
}
return {
version,
downloadUrl: found.browser_download_url,
sha256: found.digest.slice('sha256:'.length),
archive: platform.os === 'win32' ? 'zip' : 'tar.gz',
}
return { version }
}

/**
* Downloads and extracts the pnpm release archive into `destDir`, returning the
* path to the `pnpm` executable. The archive holds the executable at its root
* plus, for Node-SEA builds (v11), a sibling `dist/` it loads at runtime — the
* whole archive is extracted so that layout is preserved. The `pnpx`, `pn`, and
* `pnx` aliases are linked next to the binary, which dispatches on the name it
* was invoked as.
* Places the pnpm executable in `destDir` and returns its path.
*
* `get-pnpm` resolves the platform package, checks npm's signature over its
* checksum against a pinned key, checks the download against that checksum, and
* places the executable beside the `dist/` tree it loads. The `pnpx`, `pn` and
* `pnx` aliases are linked next to it, which the binary dispatches on.
*/
export async function downloadPnpm(resolved: ResolvedPnpm, destDir: string): Promise<string> {
const tmpDir = path.join(destDir, '.download')
await mkdir(tmpDir, { recursive: true })

const archivePath = path.join(tmpDir, resolved.archive === 'zip' ? 'pnpm.zip' : 'pnpm.tgz')
const response = await http.get(resolved.downloadUrl)
if (response.message.statusCode !== 200) {
response.message.resume()
throw new Error(`Failed to download ${resolved.downloadUrl}: HTTP ${response.message.statusCode}`)
}
await pipeline(response.message, createWriteStream(archivePath))
await verifySha256(archivePath, resolved.sha256, resolved.downloadUrl)

await extractArchive(archivePath, destDir, resolved.archive)
await rm(tmpDir, { recursive: true, force: true })
await mkdir(destDir, { recursive: true })
await downloadVerifiedPnpm({ versionSpec: resolved.version, registry: REGISTRY, dest: destDir })
// Up to v11 get-pnpm writes a manifest so that `pnpm setup` installs the
// wrapper's dependencies. This action never runs setup — it owns this
// directory and puts it on PATH — so the manifest would just be a stray
// project file sitting where commands run.
await rm(path.join(destDir, 'package.json'), { force: true })

const exe = process.platform === 'win32' ? 'pnpm.exe' : 'pnpm'
const pnpmBin = path.join(destDir, exe)
Expand All @@ -117,62 +80,6 @@ export async function downloadPnpm(resolved: ResolvedPnpm, destDir: string): Pro
return pnpmBin
}

interface Platform {
readonly os: 'linux' | 'darwin' | 'win32'
readonly arch: 'x64' | 'arm64'
readonly musl: boolean
}

function getPlatform(): Platform {
const arch = process.arch
if (arch !== 'x64' && arch !== 'arm64') {
throw new Error(`Unsupported CPU architecture "${arch}". pnpm provides executables for x64 and arm64.`)
}
const os = process.platform
if (os !== 'linux' && os !== 'darwin' && os !== 'win32') {
throw new Error(`Unsupported platform "${os}". pnpm provides executables for Windows, macOS, and Linux.`)
}
return { os, arch, musl: os === 'linux' && isMusl() }
}

// Release assets are named `pnpm-<os>-<arch>[-musl].tar.gz`, except Windows
// which ships a `.zip` (e.g. `pnpm-linux-x64.tar.gz`, `pnpm-linux-x64-musl.tar.gz`,
// `pnpm-darwin-arm64.tar.gz`, `pnpm-win32-x64.zip`).
function assetName(platform: Platform): string {
const { os, arch, musl } = platform
if (os === 'win32') return `pnpm-win32-${arch}.zip`
return `pnpm-${os}-${arch}${musl ? '-musl' : ''}.tar.gz`
}

function isMusl(): boolean {
const header = (process.report?.getReport() as { header?: { glibcVersionRuntime?: string } } | undefined)?.header
if (header) return !header.glibcVersionRuntime
return existsSync('/etc/alpine-release')
}

async function fetchRelease(version: string, token?: string): Promise<GitHubRelease> {
const headers: Record<string, string> = {
accept: 'application/vnd.github+json',
'x-github-api-version': '2022-11-28',
}
// A token lifts the anonymous 60-req/hour rate limit; the action defaults it
// to the workflow's GITHUB_TOKEN. Anonymous requests still work without one.
if (token) headers.authorization = `Bearer ${token}`

const url = `${GITHUB_API}/repos/pnpm/pnpm/releases/tags/v${version}`
const response = await http.getJson<GitHubRelease>(url, headers)
if (response.statusCode === 404) {
throw new Error(`pnpm ${version} has no GitHub release (tag v${version}). Some prerelease versions are published to npm but not released as downloadable binaries — pick a version with a published release: https://github.com/pnpm/pnpm/releases`)
}
// Any other non-200 (403 rate limit, 401 bad token, 5xx, …) still yields a
// parsed JSON error body as `result`; reject on status so it never reaches
// the caller as a bogus release.
if (response.statusCode !== 200 || response.result == null) {
throw new Error(`Failed to fetch the pnpm ${version} release from ${url}: HTTP ${response.statusCode}.`)
}
return response.result
}

async function resolveVersion(spec: string): Promise<string> {
const exact = semver.valid(spec)
if (exact) return exact
Expand Down Expand Up @@ -216,37 +123,3 @@ async function fetchJson<T>(url: string, headers?: Record<string, string>): Prom
return response.result
}

async function verifySha256(file: string, expectedHex: string, url: string): Promise<void> {
const hash = createHash('sha256')
await pipeline(createReadStream(file), hash)
const actual = hash.digest('hex')
if (actual !== expectedHex.toLowerCase()) {
throw new Error(`Integrity check failed for ${url}.
Expected sha256: ${expectedHex}
Actual sha256: ${actual}`)
}
}

function extractArchive(archivePath: string, destDir: string, archive: 'tar.gz' | 'zip'): Promise<void> {
// A tar executable is available on all GitHub-hosted runners. GNU tar
// (Linux/macOS) handles the `.tar.gz` assets; the `tar` on Windows runners is
// bsdtar, which also unpacks the `.zip` assets. Backslashes are converted to
// forward slashes because MSYS-based tar implementations misread them.
const flags = archive === 'zip' ? '-xf' : '-xzf'
const args = [flags, archivePath.replace(/\\/g, '/'), '-C', destDir.replace(/\\/g, '/')]
return new Promise<void>((resolve, reject) => {
const cp = spawn('tar', args, { stdio: ['ignore', 'inherit', 'inherit'] })
cp.on('error', (error: NodeJS.ErrnoException) => {
reject(error.code === 'ENOENT'
? new Error('Could not find a `tar` executable on PATH. tar is preinstalled on all GitHub-hosted runners; on a self-hosted runner, install tar to use this action.')
: error)
})
cp.on('close', (code) => {
if (code === 0) {
resolve()
} else {
reject(new Error(`tar exited with code ${code} while extracting ${archivePath}`))
}
})
})
}
6 changes: 3 additions & 3 deletions src/install-pnpm/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ export interface SelfInstallerResult {
}

export async function runSelfInstaller(inputs: Inputs): Promise<SelfInstallerResult> {
const { version, dest, packageJsonFile, token } = inputs
const { version, dest, packageJsonFile } = inputs

const spec = readTargetVersion({ version, packageJsonFile })
const resolved = await resolvePnpm(spec, token)
info(`Downloading pnpm ${resolved.version} from ${resolved.downloadUrl}`)
const resolved = await resolvePnpm(spec)
info(`Downloading pnpm ${resolved.version} from the npm registry`)

await rm(dest, { recursive: true, force: true })
// Create dest/bin upfront: pnpm ≤ 12.0.0-alpha.17 refuses to run
Expand Down