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
26 changes: 26 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Test

on:
push:
branches: [main]
pull_request: {}

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
# ubuntu runners ship shellcheck; lint the real bash scripts (the action's
# thin ${{ }} wiring steps aren't shell to check).
- name: Shellcheck the scripts
run: shellcheck scripts/*.sh
# Fetch and run bats with pnpm's own `pnx` (= `pnpm dlx`), dogfooding pnpm
# instead of npm's npx.
- name: Run unit and integration tests
run: pnx bats test/
131 changes: 131 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# pnpm/update

Updates the dependencies of your project with pnpm, keeps the pinned
pnpm (`packageManager` / `devEngines.packageManager`) and Node.js
(`devEngines.runtime`) versions fresh — by default within their current major
versions — and opens a pull request with the result. It can optionally update
the GitHub Actions pinned in your workflow files too. By default the lockfile
is regenerated from scratch, so transitive dependencies of unchanged packages
are refreshed too.

The default setup needs no secrets: the built-in `GITHUB_TOKEN` is enough to
update dependencies, pnpm, and Node.js, validate them with your own `verify`
commands, and open a pull request.

Unlike external dependency bots, this action runs pnpm itself, so it supports
every feature of your workspace: catalogs, patched dependencies, config
dependencies, overrides, and anything pnpm learns in the future.

The action expects pnpm (and a runtime, if your project needs one for
verification) to already be set up — pair it with [`pnpm/setup`].

## Usage

```yaml
name: Update Dependencies

on:
schedule:
- cron: '0 0 * * 1' # Every Monday at midnight UTC
workflow_dispatch: {}

permissions:
contents: write
pull-requests: write

concurrency:
group: update-dependencies
cancel-in-progress: false

jobs:
update-dependencies:
if: github.repository == 'your-org/your-repo' # Don't run on forks
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
# Installs the pnpm version from `packageManager` and the runtime
# from `devEngines.runtime`.
- uses: pnpm/setup@v1
- uses: pnpm/update@v0
with:
verify: |
pnpm build
pnpm test
```

[`pnpm/setup`]: https://github.com/pnpm/setup

## Updating GitHub Actions

The action can also bump the GitHub Actions pinned in `.github/workflows/*.yml`
and `action.yml` (via `pnpm update --include-github-actions`). This is **opt-in**
(`github-actions: true`) because GitHub does not let the default `GITHUB_TOKEN`
push changes to workflow files — you must pass a `token` that carries the
`workflow` scope (a PAT) or `workflows: write` (a GitHub App). Without such a
token the push fails as soon as an action needs updating.

```yaml
- uses: pnpm/update@v0
with:
github-actions: true
token: ${{ secrets.UPDATE_TOKEN }} # PAT with `repo` + `workflow`
```

GitHub Actions updates ride along with the dependency update, so they only
happen when `update-deps` is `latest` or `ranges` (not `false`).

## Refreshing the lockfile only

To refresh the lockfile to the latest versions matching your `package.json`
ranges without touching any manifests (and, in this example, follow pnpm's
prereleases while propagating updated versions into other files):

```yaml
- uses: pnpm/update@v0
with:
update-deps: false
update-pnpm: next-12
post-update: pnpm update-manifests
token: ${{ secrets.UPDATE_TOKEN }}
```

## Development

The action's logic lives in `scripts/` so it can be tested outside of a live
workflow:

- `scripts/lib.sh` — pure helpers (update-argument construction, `update-deps`
validation, Node.js-major extraction), unit-tested in `test/lib.bats`.
- `scripts/update.sh` — the whole "Update dependencies" step, driven end-to-end
in `test/update.bats` against a stubbed `pnpm` (`test/stubs/pnpm`) that
records the commands it would run.

Run the checks with [shellcheck](https://www.shellcheck.net) and
[bats](https://github.com/bats-core/bats-core):

```sh
shellcheck scripts/*.sh
bats test/
```

CI runs both on every push and pull request.

## Inputs

| Input | Default | Description |
|---|---|---|
| `token` | `github.token` | Token used to push the branch and create the PR. PRs created with the default `GITHUB_TOKEN` don't trigger other workflows; pass a GitHub App token or PAT if you want CI to run on the PR. With `github-actions` enabled, the token must also carry the `workflow` scope (PAT) or `workflows: write` (App) to push workflow-file changes. |
| `branch` | `chore/update-dependencies` | Branch the updates are pushed to (force-pushed on every run, so at most one update PR stays open). |
| `base` | repository default branch | Branch the updates are based on and the pull request targets. |
| `update-deps` | `latest` | How to update dependencies: `latest` ignores `package.json` ranges, `ranges` stays within them, `false` skips manifest updates entirely. |
| `refresh-lockfile` | `true` | Delete `pnpm-lock.yaml` and `node_modules` before updating, so the whole graph — including transitive dependencies — is freshly resolved. Set to `false` to keep existing resolutions where possible. |
| `exclude` | — | Whitespace-separated package name patterns whose ranges should not be updated, e.g. `typescript @types/*`. With `refresh-lockfile`, excluded packages are still re-resolved within their kept ranges. |
| `github-actions` | `false` | Set to `true` to also update the GitHub Actions pinned in `.github/workflows/*.yml` and `action.yml`. Only applies when `update-deps` is `latest` or `ranges`. Requires a `token` with the `workflow` scope (see above). |
| `post-update` | — | Shell commands run after the updates, before verification; their changes are included in the PR. |
| `changesets` | `true` | In repositories that use changesets: generate a changeset for the updated dependencies via `pnpm update --changeset` (patch for production deps, major for peer deps, and the same for packages consuming a changed `catalog:` entry). Private, ignored, and dev-only changes are skipped. Only applies in `latest`/`ranges` mode and when the installed pnpm supports `--changeset`. Set to `false` to disable. |
| `update-pnpm` | pinned major | Bump pnpm itself via `pnpm self-update`. Defaults to the latest release of the currently pinned major; set a version, range, or dist-tag (`latest`, `12`, `next-12`) to move onto it, or `false` to skip. |
| `node` | pinned major | Bump the Node.js version pinned in `devEngines.runtime`. Defaults to the latest release of the currently pinned major (skipped when nothing is pinned); set `24`, `lts`, or `latest` to move onto it, or `false` to skip. |
| `verify` | — | Shell commands run after updating (build, tests). If they fail, no PR is created. |
| `commit-message` | `chore: update dependencies` | Message of the update commit. |
| `pr-title` | `chore: update dependencies` | Title of the pull request. |
| `pr-body` | Automated dependency updates… | Body of the pull request. |
181 changes: 181 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
name: 'pnpm update'
description: 'Update dependencies (and optionally pnpm and the runtime) with pnpm, then open a pull request'
branding:
icon: 'refresh-cw'
color: 'orange'
inputs:
token:
description: >-
Token used to push the update branch and create the pull request.
Pull requests created with the default GITHUB_TOKEN do not trigger other
workflows; pass a GitHub App token or PAT if you want CI to run on the PR.
When github-actions is enabled, the token must additionally carry the
`workflow` scope (PAT) or `workflows: write` (App) to push changes to
.github/workflows files.
default: ${{ github.token }}
branch:
description: >-
Branch the updates are pushed to. It is force-pushed on every run, so at
most one update PR stays open at a time.
default: 'chore/update-dependencies'
base:
description: 'Branch the updates are based on and the pull request targets.'
default: ${{ github.event.repository.default_branch }}
update-deps:
description: >-
How to update dependencies with `pnpm update`. "latest" updates them to
their latest versions, ignoring the ranges declared in package.json;
"ranges" updates them within those ranges; "false" skips manifest
updates entirely (combined with refresh-lockfile, this refreshes the
lockfile without touching any package.json).
default: 'latest'
refresh-lockfile:
description: >-
Delete pnpm-lock.yaml and node_modules before updating, so the whole
dependency graph — including transitive dependencies of unchanged
packages — is freshly resolved instead of reused from the existing
lockfile. Set to "false" to keep existing resolutions where possible.
default: 'true'
exclude:
description: >-
Whitespace-separated package name patterns whose package.json ranges
should not be updated. Example: "typescript @types/*". Note that with
refresh-lockfile, excluded packages are still re-resolved within their
kept ranges.
default: ''
github-actions:
description: >-
Set to "true" to also update the GitHub Actions pinned in
.github/workflows/*.yml and action.yml (via
`pnpm update --include-github-actions`). Opt-in because pushing
workflow-file changes requires a token with the `workflow` scope (a PAT)
or `workflows: write` (a GitHub App); the default GITHUB_TOKEN cannot, so
enabling it without such a token fails the push. Only applies when
update-deps is "latest" or "ranges".
default: 'false'
post-update:
description: >-
Shell commands run after the updates, before verification and the
commit. Useful for propagating updated versions into other files;
their changes are included in the pull request.
default: ''
changesets:
description: >-
Generate a changeset for the updated dependencies via
`pnpm update --changeset`, so the next release ships them: a patch bump
for changed production dependencies, a major bump for changed peer
dependencies, and the same for packages consuming a changed `catalog:`
entry. Only applies when update-deps is "latest" or "ranges", the
repository uses changesets (.changeset/config.json exists), and the
installed pnpm supports `--changeset`. Set to "false" to disable (which
also passes `--no-changeset`, overriding a repo-level `update.changeset`).
default: 'true'
update-pnpm:
description: >-
How to update the pinned pnpm version (packageManager and
devEngines.packageManager) via `pnpm self-update`. By default, updates
to the latest release of the currently pinned major version. Set to a
version, range, or dist-tag (e.g. "latest", "12", "next-12") to move
onto that instead, or "false" to skip.
default: ''
node:
description: >-
How to update the Node.js version pinned in devEngines.runtime. By
default, updates to the latest release of the currently pinned major
version (skipped when no Node.js version is pinned). Set to a spec
accepted by `pnpm runtime set node` (e.g. "24", "lts", "latest") to
move onto that instead, or "false" to skip.
default: ''
verify:
description: >-
Shell commands run after updating (e.g. build and tests). If they fail,
no pull request is created.
default: ''
commit-message:
description: 'Message of the update commit.'
default: 'chore: update dependencies'
pr-title:
description: 'Title of the pull request.'
default: 'chore: update dependencies'
pr-body:
description: 'Body of the pull request.'
default: 'Automated dependency updates generated with `pnpm update`.'
runs:
using: 'composite'
steps:
- name: Prepare the update branch
shell: bash
env:
BRANCH: ${{ inputs.branch }}
BASE: ${{ inputs.base }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Base the update on the latest base branch, even when the workflow
# was dispatched from another ref or the checkout is shallow.
git fetch origin "$BASE"
git checkout -B "$BRANCH" FETCH_HEAD

- name: Update dependencies
shell: bash
env:
UPDATE_DEPS: ${{ inputs.update-deps }}
REFRESH_LOCKFILE: ${{ inputs.refresh-lockfile }}
EXCLUDE: ${{ inputs.exclude }}
# Not GITHUB_ACTIONS: the runner already sets that to "true".
INCLUDE_GITHUB_ACTIONS: ${{ inputs.github-actions }}
CHANGESETS: ${{ inputs.changesets }}
UPDATE_PNPM: ${{ inputs.update-pnpm }}
NODE: ${{ inputs.node }}
run: bash "$GITHUB_ACTION_PATH/scripts/update.sh"

- name: Run post-update commands
if: ${{ inputs.post-update != '' }}
shell: bash
run: ${{ inputs.post-update }}

- name: Verify the updated project
if: ${{ inputs.verify != '' }}
shell: bash
run: ${{ inputs.verify }}

- name: Commit, push, and create the pull request
shell: bash
env:
GH_TOKEN: ${{ inputs.token }}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
BRANCH: ${{ inputs.branch }}
BASE: ${{ inputs.base }}
COMMIT_MESSAGE: ${{ inputs.commit-message }}
PR_TITLE: ${{ inputs.pr-title }}
PR_BODY: ${{ inputs.pr-body }}
run: |
set -euo pipefail

if [ -z "$(git status --porcelain)" ]; then
echo "Everything is up to date."
exit 0
fi

git add -A
git commit -m "$COMMIT_MESSAGE"

# Remove any credentials persisted by actions/checkout: they would
# take precedence over the token this action was given, silently
# downgrading a user-supplied PAT or App token to GITHUB_TOKEN.
git config --local --unset-all "http.${GITHUB_SERVER_URL:-https://github.com}/.extraheader" || true
# Supply the token through a credential helper (it reads GH_TOKEN
# from the environment) so it never appears on a command line.
git -c credential.helper= \
-c credential.helper='!f() { echo username=x-access-token; echo "password=${GH_TOKEN}"; }; f' \
push --force origin "$BRANCH"

# A PR left open by a previous run already points at the branch we
# just force-pushed, so there is nothing more to do.
if [ -z "$(gh pr list --head "$BRANCH" --state open --json number --jq '.[].number')" ]; then
gh pr create \
--title "$PR_TITLE" \
--body "$PR_BODY" \
--base "$BASE" \
--head "$BRANCH"
fi
52 changes: 52 additions & 0 deletions scripts/lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Pure helpers for the pnpm/update action, kept in a sourceable library so they
# can be unit-tested (see test/lib.bats) without running the whole action.
# Nothing here has side effects or calls pnpm/git — the action wires these into
# its steps.

# Validate the `update-deps` input. Prints a GitHub error annotation and returns
# non-zero on an unknown value.
validate_update_deps() {
case "$1" in
latest | ranges | false) return 0 ;;
*)
echo "::error::Invalid value for the update-deps input: ${1}. Expected latest, ranges, or false."
return 1
;;
esac
}

# Print the arguments for `pnpm update`, one per line, given:
# $1 update-deps (latest|ranges — the caller handles "false")
# $2 include-github-actions (true|false)
# $3 exclude (whitespace-separated name patterns)
# $4 changeset-arg (--changeset, --no-changeset, or empty)
# The exclude string is deliberately word-split; the caller runs with `set -f`
# so patterns like "@types/*" reach pnpm as negation selectors rather than
# globbing against the working tree.
pnpm_update_args() {
local update_deps="$1" include_actions="$2" exclude="$3" changeset_arg="${4:-}" pattern
printf '%s\n' --recursive
[ "$update_deps" = latest ] && printf '%s\n' --latest
[ "$include_actions" = true ] && printf '%s\n' --include-github-actions
[ -n "$changeset_arg" ] && printf '%s\n' "$changeset_arg"
# shellcheck disable=SC2086 # intentional word splitting; caller sets -f
for pattern in $exclude; do
printf '!%s\n' "$pattern"
done
return 0
}

# Print the pinned Node.js major from a package.json's `devEngines.runtime`,
# handling both the single-object and array forms. Prints nothing (and returns
# 0) when no Node.js runtime is pinned or the file is unreadable.
node_major_from_manifest() {
local file="${1:-package.json}" pinned
pinned="$(jq -r '
.devEngines.runtime // empty
| if type == "array" then .[] else . end
| select(.name == "node") | .version // empty
' "$file" 2>/dev/null | head -n 1)"
[ -n "$pinned" ] || return 0
printf '%s' "$pinned" | grep -oE '[0-9]+' | head -n 1
}
Loading
Loading