Skip to content
Open
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
40 changes: 31 additions & 9 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ jobs:

runtime-from-devengines:
# No `runtime` input — the action should pick up devEngines.runtime from
# package.json and install it. Also asserts that `pnpm install` runs by
# package.json and install every entry. Also asserts that `pnpm install` runs by
# default when a manifest is present.
Comment thread
BlankParticle marked this conversation as resolved.
name: 'Runtime from devEngines.runtime'
runs-on: ubuntu-latest
Expand All @@ -337,7 +337,11 @@ jobs:
{
"packageManager": "pnpm@12.0.0-beta.4",
"devEngines": {
"runtime": { "name": "node", "version": "^22.0.0", "onFail": "download" }
"runtime": [
{ "name": "bun", "version": "1.3.12", "onFail": "warn" },
{ "name": "node", "version": "24", "onFail": "warn" },
{ "name": "bun", "version": "1.3.13", "onFail": "warn" }
]
},
"dependencies": {
"is-odd": "3.0.1"
Expand All @@ -349,25 +353,38 @@ jobs:
- id: pnpm
uses: ./

- name: 'Test: node 22 is installed and dependencies are resolved'
- name: 'Test: Bun and Node are installed and dependencies are resolved'
env:
OUT_NAME: ${{ steps.pnpm.outputs.runtime-name }}
OUT_VERSION: ${{ steps.pnpm.outputs.runtime-version }}
OUT_RUNTIMES: ${{ steps.pnpm.outputs.runtimes }}
run: |
set -e
which bun
bun_actual="$(bun --version)"
echo "bun --version: ${bun_actual}"
if [ "${bun_actual}" != "1.3.13" ]; then
echo "Expected bun 1.3.13, got ${bun_actual}"
exit 1
fi
which node
actual="$(node --version)"
echo "node --version: ${actual}"
case "${actual}" in
v22.*) ;;
*) echo "Expected node v22.x, got ${actual}"; exit 1 ;;
v24.*) ;;
*) echo "Expected node v24.x, got ${actual}"; exit 1 ;;
esac
if [ "${OUT_NAME}" != "node" ]; then
echo "Expected outputs.runtime-name=node, got ${OUT_NAME}"
if [ "${OUT_NAME}" != "bun" ]; then
echo "Expected outputs.runtime-name=bun, got ${OUT_NAME}"
exit 1
fi
if [ "${OUT_VERSION}" != "1.3.13" ]; then
echo "Expected outputs.runtime-version=1.3.13, got ${OUT_VERSION}"
exit 1
fi
if [ "${OUT_VERSION}" != "^22.0.0" ]; then
echo "Expected outputs.runtime-version=^22.0.0, got ${OUT_VERSION}"
expected='[{"name":"bun","version":"1.3.13"},{"name":"node","version":"24"}]'
if [ "${OUT_RUNTIMES}" != "${expected}" ]; then
echo "Expected outputs.runtimes=${expected}, got ${OUT_RUNTIMES}"
exit 1
fi
# `pnpm install` should have run automatically — node_modules must exist.
Expand Down Expand Up @@ -614,6 +631,7 @@ jobs:
env:
OUT_NAME: ${{ steps.pnpm.outputs.runtime-name }}
OUT_VERSION: ${{ steps.pnpm.outputs.runtime-version }}
OUT_RUNTIMES: ${{ steps.pnpm.outputs.runtimes }}
run: |
set -e
which pnpm
Expand All @@ -626,4 +644,8 @@ jobs:
echo "Expected outputs.runtime-version to be empty, got '${OUT_VERSION}'"
exit 1
fi
if [ "${OUT_RUNTIMES}" != "[]" ]; then
echo "Expected outputs.runtimes=[], got '${OUT_RUNTIMES}'"
exit 1
fi
shell: bash
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,17 @@ pnpm ships a self-contained release binary — the action downloads it for the r
>
> One caveat: pnpm v11 publishes no binary for Intel macOS (`darwin-x64`); use v12 or newer on Intel macOS runners.

If your `package.json` declares `devEngines.runtime`, the action picks up the runtime and version from there automatically — no inputs required.
If your `package.json` declares `devEngines.runtime`, the action picks up every runtime and version from there automatically — no inputs required.

Only one version of each runtime can be installed globally. If a runtime name is declared more than once, the action emits a GitHub warning annotation and installs the last declared version while retaining the position of its first declaration.

## Inputs

| Name | Description |
|------|-------------|
| `version` | Version of pnpm to install: an exact version, a semver range (`^12.0.0`), or a dist-tag (`next-12`). Must resolve to v11 or newer. Optional when `packageManager` or `devEngines.packageManager` is set in `package.json`. |
| `dest` | Where to store pnpm files. Defaults to `~/setup-pnpm`. |
| `runtime` | Runtime spec, in `<name>` or `<name>@<version>` form (e.g. `node@22`, `node@lts`, `bun@latest`, `deno@2`). Supported names: `node`, `bun`, `deno`. When the version is omitted, falls back to `devEngines.runtime` in `package.json`, then to `lts` (for `node`) / `latest`. If the input itself is omitted, the action reads `devEngines.runtime` from `package.json`. |
| `runtime` | Runtime spec, in `<name>` or `<name>@<version>` form (e.g. `node@22`, `node@lts`, `bun@latest`, `deno@2`). Supported names: `node`, `bun`, `deno`. When the version is omitted, falls back to `devEngines.runtime` in `package.json`, then to `lts` (for `node`) / `latest`. If the input itself is omitted, the action installs every entry in `devEngines.runtime` from `package.json`. |
| `cache` | Cache the pnpm store directory. Default: `false`. |
| `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`. |
Expand All @@ -30,8 +32,9 @@ If your `package.json` declares `devEngines.runtime`, the action picks up the ru
|------|-------------|
| `dest` | Expanded path of `dest`. |
| `bin-dest` | Directory containing the `pnpm` / `pnpx` binaries. |
| `runtime-name` | Name of the installed runtime, or empty string if none was installed. |
| `runtime-version` | Resolved version of the installed runtime, or empty string if none was installed. |
| `runtime-name` | Name of the first installed runtime, or empty string if none was installed. |
| `runtime-version` | Resolved version of the first installed runtime, or empty string if none was installed. |
| `runtimes` | JSON array of every installed runtime in declaration order, as `{ "name": string, "version": string }` objects. Returns `[]` when none were installed. |

## Usage

Expand Down Expand Up @@ -112,8 +115,8 @@ For jobs that only need pnpm itself — e.g. `pnpm audit`, lockfile-only regener

1. The action resolves the requested version (exact, range, or dist-tag) against the npm registry, then downloads the matching self-contained release archive for the runner's platform (`pnpm-<os>-<arch>.tar.gz`, or `pnpm-win32-<arch>.zip` on Windows) from pnpm's GitHub releases. It verifies the archive against the SHA-256 digest GitHub publishes for the asset, extracts the `pnpm` executable (and, for pnpm builds that need it, its bundled `dist/`), and links the `pnpx`, `pn`, and `pnx` aliases into `dest`. No Node.js or npm is involved.
2. `PNPM_HOME` is exported and `dest` plus `$PNPM_HOME/bin` are added to `PATH`.
3. The action runs `pnpm runtime set <name> <version> -g`, which downloads the requested runtime into `$PNPM_HOME/bin` — making `node`, `bun`, or `deno` available to later workflow steps. It then exports `PNPM_CONFIG_GLOBAL_SHIMS={"<name>":false}` so that runtime stays the one later steps get; see [Context-aware global shims](#context-aware-global-shims).
4. If a `package.json` exists in the workspace, the action runs `pnpm install` (unless `install: false` is set). When the `runtime` input is set, `--no-runtime` is appended so the just-installed runtime isn't shadowed by a different version declared in `devEngines.runtime`.
3. The action runs `pnpm runtime set <name> <version> -g` for every requested runtime, which downloads them into `$PNPM_HOME/bin` and makes them available to later workflow steps. It then disables context-aware shims for every installed runtime; see [Context-aware global shims](#context-aware-global-shims).
4. If a `package.json` exists in the workspace, the action runs `pnpm install` (unless `install: false` is set). When runtimes were installed, `--no-runtime` is appended because the action has already processed `devEngines.runtime`.

### Context-aware global shims

Expand Down
19 changes: 10 additions & 9 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ inputs:
declared in `devEngines.runtime` for the same name, or to `lts` (for
`node`) / `latest` for others.

If this input is omitted entirely, the action reads `devEngines.runtime`
from the project's package.json. If that is also missing, no runtime is
installed.
If this input is omitted entirely, the action installs every runtime in
`devEngines.runtime` from the project's package.json. In case of duplicates,
the last declared version wins. If the field is missing, no runtime is installed.

Whenever a runtime is installed, the action exports
`PNPM_CONFIG_GLOBAL_SHIMS` with that runtime's context-aware shim
Expand All @@ -55,10 +55,9 @@ inputs:
Whether to run `pnpm install` after pnpm and the runtime are set up.

When set to `true` (the default), the action runs `pnpm install` in the
workspace when a package.json is present. When the `runtime` input is
also set, `--no-runtime` is appended automatically so the installed
runtime isn't shadowed by a different version declared in
`devEngines.runtime`.
workspace when a package.json is present. When runtimes are installed,
`--no-runtime` is appended automatically because the action has already
processed `devEngines.runtime`.

Set to `false` to skip the install step — useful for jobs that only
need pnpm itself (e.g. `pnpm audit`, lockfile-only regeneration).
Expand All @@ -78,9 +77,11 @@ outputs:
bin-dest:
description: Location of `pnpm` and `pnpx` command
runtime-name:
description: Name of the installed runtime (`node`, `bun`, or `deno`), or empty if none was installed
description: Name of the first installed runtime (`node`, `bun`, or `deno`), or empty if none was installed
runtime-version:
description: Resolved version of the installed runtime, or empty if none was installed
description: Resolved version of the first installed runtime, or empty if none was installed
runtimes:
description: JSON array of installed runtimes, each containing `name` and `version`
runs:
using: node24
main: dist/index.js
Expand Down
260 changes: 130 additions & 130 deletions dist/index.js

Large diffs are not rendered by default.

24 changes: 17 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import restoreCache from './cache-restore'
import saveCache from './cache-save'
import getInputs, { Inputs } from './inputs'
import installPnpm from './install-pnpm'
import { resolveRuntimeRequest, installRuntime, InstalledRuntime, logSkippedRuntime } from './install-runtime'
import {
resolveRuntimeRequests,
installRuntime,
InstalledRuntime,
keepInstalledRuntimesAuthoritative,
logSkippedRuntime,
} from './install-runtime'
import setOutputs from './outputs'
import pnpmInstall from './pnpm-install'
import pruneStore from './pnpm-store-prune'
Expand All @@ -24,21 +30,25 @@ async function runMain() {
const result = await installPnpm(inputs)
console.log('Installation Completed!')

let runtime: InstalledRuntime | undefined
const request = resolveRuntimeRequest(inputs)
if (request) {
runtime = await installRuntime(request, result.binDest)
const runtimes: InstalledRuntime[] = []
const requests = resolveRuntimeRequests(inputs)
for (const request of requests) {
const runtime = await installRuntime(request, result.binDest)
if (runtime === undefined) return
Comment thread
greptile-apps[bot] marked this conversation as resolved.
runtimes.push(runtime)
}
if (runtimes.length > 0) {
keepInstalledRuntimesAuthoritative(runtimes)
} else {
logSkippedRuntime()
}

setOutputs(inputs, result.binDest, runtime)
setOutputs(inputs, result.binDest, runtimes)

await restoreCache(inputs)

if (inputs.install) {
pnpmInstall(inputs)
pnpmInstall(inputs, runtimes.length > 0)
}
}

Expand Down
41 changes: 27 additions & 14 deletions src/install-runtime/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { exportVariable, setFailed, startGroup, endGroup, info } from '@actions/core'
import { exportVariable, setFailed, startGroup, endGroup, info, warning } from '@actions/core'
import { spawn } from 'child_process'
import { readFileSync } from 'fs'
import path from 'path'
Expand All @@ -23,17 +23,17 @@ export interface RuntimeRequest {
readonly version: string
}

export function resolveRuntimeRequest(inputs: Inputs): RuntimeRequest | undefined {
export function resolveRuntimeRequests(inputs: Inputs): RuntimeRequest[] {
// Explicit `runtime` input always wins. `runtime.version` falls back to
// devEngines.runtime if not provided — useful for matrix workflows that
// pick the runtime but keep the version pinned in the manifest.
if (inputs.runtime) {
const { name } = inputs.runtime
const version = inputs.runtime.version ?? readDevEngineVersion(inputs, name) ?? defaultVersionFor(name)
return { name, version }
return [{ name, version }]
}

return readFirstDevEngineRuntime(inputs)
return readDevEngineRuntimes(inputs)
}

export async function installRuntime(
Expand All @@ -55,7 +55,6 @@ export async function installRuntime(
setFailed(`pnpm runtime set ${request.name} ${request.version} -g exited with code ${exitCode}`)
return undefined
}
keepInstalledRuntimeAuthoritative(request.name)
return { name: request.name, version: request.version }
}

Expand All @@ -66,18 +65,24 @@ export async function installRuntime(
* the version this action was asked to install — a matrix job asking for
* `node@22` would run the repository's pinned version instead — and even
* when the two agree it materializes a second copy outside `$PNPM_HOME`.
* Turn the shim off for the runtime we installed, leaving every other
* Turn the shims off for the runtimes we installed, leaving every other
* runtime at pnpm's defaults. A value the workflow set itself always wins.
*/
function keepInstalledRuntimeAuthoritative(name: RuntimeName) {
export function keepInstalledRuntimesAuthoritative(runtimes: readonly InstalledRuntime[]) {
if (runtimes.length === 0) return

// An empty value counts as unset, the same rule pnpm applies when it reads
// these — stepping aside for a value pnpm ignores would leave the shim on.
// these — stepping aside for a value pnpm ignores would leave the shims on.
const configured = GLOBAL_SHIMS_ENV_NAMES.find(envName => process.env[envName])
if (configured) {
info(`\`${configured}\` is already set; leaving pnpm's context-aware shims as configured.`)
return
}
exportVariable(GLOBAL_SHIMS_ENV_NAMES[0], JSON.stringify({ [name]: false }))

exportVariable(
GLOBAL_SHIMS_ENV_NAMES[0],
JSON.stringify(Object.fromEntries(runtimes.map(runtime => [runtime.name, false]))),
)
}

export function logSkippedRuntime() {
Expand Down Expand Up @@ -119,13 +124,21 @@ function readDevEngineVersion(inputs: Inputs, name: RuntimeName): string | undef
return match?.version
}

function readFirstDevEngineRuntime(inputs: Inputs): RuntimeRequest | undefined {
function readDevEngineRuntimes(inputs: Inputs): RuntimeRequest[] {
const runtimes = new Map<RuntimeName, RuntimeRequest>()
for (const entry of readDevEngineEntries(inputs)) {
if (!entry.name || !entry.version) continue
if (!SUPPORTED_RUNTIMES.has(entry.name as RuntimeName)) continue
return { name: entry.name as RuntimeName, version: entry.version }
if (!entry.name || !entry.version || !SUPPORTED_RUNTIMES.has(entry.name as RuntimeName)) continue

const name = entry.name as RuntimeName
const previous = runtimes.get(name)
if (previous) {
warning(
`Duplicate ${name} runtime versions declared in devEngines.runtime (${previous.version} and ${entry.version}); using the last declared version ${entry.version}.`,
)
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
runtimes.set(name, { name, version: entry.version })
}
return undefined
return [...runtimes.values()]
}

function runPnpm(binDest: string, args: string[]): Promise<number> {
Expand Down
8 changes: 5 additions & 3 deletions src/outputs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { setOutput } from '@actions/core'
import { Inputs } from '../inputs'
import { InstalledRuntime } from '../install-runtime'

export function setOutputs(inputs: Inputs, binDest: string, runtime: InstalledRuntime | undefined) {
export function setOutputs(inputs: Inputs, binDest: string, runtimes: readonly InstalledRuntime[]) {
// NOTE: addPath is already called in installPnpm — do not call it again
// here, as a second addPath would shadow the correct entry on Windows.
const firstRuntime = runtimes[0]
setOutput('dest', inputs.dest)
setOutput('bin-dest', binDest)
setOutput('runtime-name', runtime?.name ?? '')
setOutput('runtime-version', runtime?.version ?? '')
setOutput('runtime-name', firstRuntime?.name ?? '')
setOutput('runtime-version', firstRuntime?.version ?? '')
Comment thread
BlankParticle marked this conversation as resolved.
setOutput('runtimes', JSON.stringify(runtimes))
}

export default setOutputs
10 changes: 4 additions & 6 deletions src/pnpm-install/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { existsSync } from 'fs'
import path from 'path'
import { Inputs } from '../inputs'

export function runPnpmInstall(inputs: Inputs) {
export function runPnpmInstall(inputs: Inputs, runtimeInstalled = Boolean(inputs.runtime)) {
// Skip if there's no package.json in the workspace — the action is also
// useful for jobs that just want pnpm + a runtime on PATH (e.g. running
// global tooling, ad-hoc scripts) and have no manifest to install.
Expand All @@ -19,12 +19,10 @@ export function runPnpmInstall(inputs: Inputs) {
return
}

// When the user pinned a runtime explicitly via the `runtime` input, we've
// already installed it via `pnpm runtime set` above. Pass `--no-runtime`
// to `pnpm install` so the explicit runtime isn't shadowed by a different
// version from `devEngines.runtime` on the same install.
// The requested runtimes were already installed via `pnpm runtime set`.
// Prevent `pnpm install` from processing devEngines.runtime again.
const args = ['install']
if (inputs.runtime) {
if (runtimeInstalled) {
args.push('--no-runtime')
}

Expand Down