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
213 changes: 213 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,219 @@ jobs:
pnpm --version
shell: bash

install-require-lockfile:
# `install: require-lockfile` runs `pnpm install --frozen-lockfile`: it
# installs against an up-to-date lockfile, and fails rather than updating
# one that has drifted from package.json.
name: 'install: require-lockfile'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: Set up a manifest with a matching lockfile
run: |
rm -f pnpm-lock.yaml
cat > package.json <<'EOF'
{
"packageManager": "pnpm@12.0.0-beta.4",
"dependencies": {
"is-odd": "3.0.1"
}
}
EOF
shell: bash

- name: Put pnpm on PATH without installing
uses: ./
with:
version: '12.0.0-beta.4'
install: false

- name: Write the lockfile and record its checksum
# Recorded before any require-lockfile run, so both the successful and
# the failing install below are checked against the original file.
run: |
pnpm install --lockfile-only
sha256sum pnpm-lock.yaml > lockfile.sha256
shell: bash

- uses: ./
with:
version: '12.0.0-beta.4'
install: require-lockfile

- name: 'Test: dependencies installed from the lockfile, which is untouched'
run: |
set -e
if [ ! -d node_modules/is-odd ]; then
echo "Expected install: require-lockfile to populate node_modules/is-odd"; exit 1
fi
if ! sha256sum --check --status lockfile.sha256; then
echo "Expected a successful require-lockfile install to leave the lockfile alone"; exit 1
fi
shell: bash

- name: Add a dependency the lockfile does not know about
run: |
cat > package.json <<'EOF'
{
"packageManager": "pnpm@12.0.0-beta.4",
"dependencies": {
"is-odd": "3.0.1",
"is-even": "1.0.0"
}
}
EOF
shell: bash

- id: outdated
continue-on-error: true
uses: ./
with:
version: '12.0.0-beta.4'
install: require-lockfile

- name: 'Test: the outdated lockfile failed the step'
env:
OUTCOME: ${{ steps.outdated.outcome }}
run: |
set -e
echo "outcome: ${OUTCOME}"
if [ "${OUTCOME}" != "failure" ]; then
echo "Expected install: require-lockfile to fail on an outdated lockfile"; exit 1
fi
if ! sha256sum --check --status lockfile.sha256; then
echo "Expected the lockfile not to be updated by require-lockfile"; exit 1
fi
echo "ok: lockfile was left untouched"
shell: bash

install-ci:
# `install: ci` runs `pnpm ci` — `pnpm clean` followed by
# `pnpm install --frozen-lockfile` — so a stale node_modules entry left by
# an earlier step (or a restored cache) is gone afterwards, which is what
# separates it from a plain `pnpm install`.
name: 'install: ci'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: Set up a manifest and a stale node_modules entry
# `devEngines.runtime` deliberately conflicts with the `runtime` input
# below so the node-version assertion actually tests `--no-runtime`
# rather than passing either way.
run: |
rm -f pnpm-lock.yaml
cat > package.json <<'EOF'
{
"packageManager": "pnpm@12.0.0-beta.4",
"devEngines": {
"runtime": { "name": "node", "version": "^20.0.0", "onFail": "download" }
},
"dependencies": {
"is-odd": "3.0.1"
}
}
EOF
mkdir -p node_modules/stale-package
echo '{"name":"stale-package","version":"1.0.0"}' > node_modules/stale-package/package.json
shell: bash

- name: Write the lockfile `pnpm ci` will install from
uses: ./
with:
version: '12.0.0-beta.4'
install: false

- run: pnpm install --lockfile-only --no-runtime
shell: bash

- name: 'Precondition: the stale entry is still there'
# Without this the cleanup assertion below could pass vacuously.
run: |
set -e
if [ ! -d node_modules/stale-package ]; then
echo "Setup is broken: node_modules/stale-package is gone before the action ran"; exit 1
fi
shell: bash

- uses: ./
with:
version: '12.0.0-beta.4'
runtime: node@22
install: ci

- name: 'Test: node_modules rebuilt from scratch'
run: |
set -e
if [ ! -d node_modules/is-odd ]; then
echo "Expected install: ci to populate node_modules/is-odd"; exit 1
fi
if [ -d node_modules/stale-package ]; then
echo "Expected install: ci to clean node_modules, but stale-package survived"; exit 1
fi
# `--no-runtime` is still appended on the `ci` path: without it the
# install would fetch the node ^20 from devEngines.runtime and
# shadow the node 22 the action just installed.
node_version="$(node --version)"
echo "node --version: ${node_version}"
case "${node_version}" in
v22.*) ;;
*) echo "Expected node v22.x to stay active after pnpm ci, got ${node_version}"; exit 1 ;;
esac
shell: bash

install-invalid:
# An unrecognized `install` value is a typo, not a silent fallback to the
# default install. Neither is an empty one — the action.yml default applies
# only when the input is omitted, so an empty value means an expression
# resolved to nothing.
name: 'install: rejects an unknown value'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- id: invalid
continue-on-error: true
uses: ./
with:
version: '12.0.0-beta.4'
install: frozen

- id: empty
continue-on-error: true
uses: ./
with:
version: '12.0.0-beta.4'
install: ''

- id: control
# Same inputs but a valid `install`, so a failure above can be
# attributed to the input rather than to the runner or the network.
uses: ./
with:
version: '12.0.0-beta.4'
install: require-lockfile

- name: 'Test: only the invalid values failed'
env:
OUTCOME: ${{ steps.invalid.outcome }}
EMPTY: ${{ steps.empty.outcome }}
CONTROL: ${{ steps.control.outcome }}
run: |
set -e
echo "invalid: ${OUTCOME}, empty: ${EMPTY}, control: ${CONTROL}"
if [ "${OUTCOME}" != "failure" ]; then
echo "Expected an invalid install value to fail the step"; exit 1
fi
if [ "${EMPTY}" != "failure" ]; then
echo "Expected an empty install value to fail the step"; exit 1
fi
if [ "${CONTROL}" != "success" ]; then
echo "Control step failed, so the failure above is not attributable to the input"; exit 1
fi
shell: bash

no-runtime:
# No runtime input, no devEngines.runtime. Action installs pnpm only and
# leaves the runtime outputs empty. With no package.json, `pnpm install`
Expand Down
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ If your `package.json` declares `devEngines.runtime`, the action picks up the ru
| `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`. |
| `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). |
| `install` | Which install to run after setup: `true` (default, same as `install`) → `pnpm install`; `require-lockfile` → `pnpm install --frozen-lockfile`; `ci` → `pnpm ci`; `false` → skip it. |
| `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. |

## Outputs
Expand Down Expand Up @@ -97,6 +97,32 @@ jobs:
cache: true
```

### Choose how dependencies are installed

By default the action runs a plain `pnpm install`. The `install` input picks a stricter install instead:

```yaml
# The install must be fully described by pnpm-lock.yaml.
# Fails if the lockfile is missing or out of date.
- uses: pnpm/setup@v2
with:
install: require-lockfile

# `pnpm clean` + `pnpm install --frozen-lockfile`: rebuild
# node_modules from scratch instead of reconciling it.
- uses: pnpm/setup@v2
with:
install: ci
```

`require-lockfile` runs `pnpm install --frozen-lockfile`. It fails with `ERR_PNPM_NO_LOCKFILE` when there is no `pnpm-lock.yaml`, and with `ERR_PNPM_OUTDATED_LOCKFILE` when the lockfile has drifted from `package.json`. In both cases the lockfile is left untouched. `ci` requires a lockfile on the same terms.

> [!NOTE]
> This is not the same as pnpm's own CI default. pnpm 11 enables `--frozen-lockfile` when it detects a CI environment, but that only prevents an *existing* lockfile from being updated — with no lockfile at all, pnpm still resolves from the registry and writes one, and the build passes. pnpm 12 does not apply the CI default at all, as of 12.0.0-rc.3. `require-lockfile` makes the behaviour explicit and identical across both.

> [!NOTE]
> `pnpm ci` removes `node_modules` before installing, so anything an earlier step left there is discarded. Two caveats: a `clean` script in your `package.json` overrides `pnpm clean`, in which case that script runs instead and `node_modules` survives; and this action's `cache` input caches the pnpm store, never `node_modules`, so `install: ci` is not guarding against a poisoned dependency cache.

### Skip `pnpm install`

For jobs that only need pnpm itself — e.g. `pnpm audit`, lockfile-only regeneration — set `install: false`:
Expand All @@ -113,7 +139,7 @@ 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.
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`.
4. If a `package.json` exists in the workspace, the action runs the install selected by the `install` input — `pnpm install` by default, `pnpm install --frozen-lockfile` (`require-lockfile`) or `pnpm ci` on request, or nothing at all with `install: false`. 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`.

## License

Expand Down
38 changes: 30 additions & 8 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,38 @@ inputs:
default: 'package.json'
install:
description: |
Whether to run `pnpm install` after pnpm and the runtime are set up.
Which install to run after pnpm and the runtime are set up. The install
only runs when a package.json is present in the workspace.

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`.
- `true` (the default) or `install`: `pnpm install`
- `require-lockfile`: `pnpm install --frozen-lockfile` — the install has
to be fully described by `pnpm-lock.yaml`. It fails if the lockfile is
missing (`ERR_PNPM_NO_LOCKFILE`) or out of date with package.json
(`ERR_PNPM_OUTDATED_LOCKFILE`) instead of resolving and writing one.
- `ci`: `pnpm ci` — `pnpm clean` followed by
`pnpm install --frozen-lockfile`, so it requires a lockfile on the same
terms as `require-lockfile`
- `false`: skip the install step — useful for jobs that only need pnpm
itself (e.g. `pnpm audit`, lockfile-only regeneration)

Set to `false` to skip the install step — useful for jobs that only
need pnpm itself (e.g. `pnpm audit`, lockfile-only regeneration).
Anything else is an error, including an empty value — the default above
applies only when the input is omitted entirely, so an expression that
resolves to an empty string fails rather than silently installing.

`require-lockfile` is not the same as pnpm's own CI default. pnpm 11
turns `--frozen-lockfile` on when it detects a CI environment, but that
only stops an existing lockfile from being updated — a missing lockfile
is still resolved from the registry and written. pnpm 12 does not apply
the CI default at all, as of 12.0.0-rc.3. This input makes the behaviour
explicit and identical on both.

Note that `pnpm clean` — and therefore the clean half of `pnpm ci` — is
overridden by a `clean` script in package.json, in which case that script
runs instead and `node_modules` is not removed.

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`.
required: false
default: 'true'
token:
Expand Down
Loading