From 17326a24755cfd83c98ee1c92e8bca976d003519 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Tue, 21 Jul 2026 11:21:48 +0200 Subject: [PATCH 01/13] feat: initial version of the pnpm/update action --- README.md | 65 +++++++++++++++++++++++++++ action.yml | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 README.md create mode 100644 action.yml diff --git a/README.md b/README.md new file mode 100644 index 0000000..7db56a9 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# pnpm/update + +Updates the dependencies of your project with `pnpm update`, optionally bumps +the pinned pnpm (`packageManager` / `devEngines.packageManager`) and Node.js +(`devEngines.runtime`) versions, and opens a pull request with the result. + +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: + node: 24 + verify: | + pnpm build + pnpm test +``` + +[`pnpm/setup`]: https://github.com/pnpm/setup + +## 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. | +| `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 | Base branch of the pull request. | +| `latest` | `true` | Update to the latest versions, ignoring `package.json` ranges. Set to `false` to update within ranges. | +| `exclude` | — | Whitespace-separated package name patterns that should not be updated, e.g. `typescript @types/*`. | +| `update-pnpm` | `latest` | Bump pnpm itself via `pnpm self-update`. A dist-tag or exact version, or `false` to skip. | +| `node` | — | Bump the Node.js version pinned in `devEngines.runtime` to the latest release of this major, e.g. `24`. Empty 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. | diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..dfdce4d --- /dev/null +++ b/action.yml @@ -0,0 +1,127 @@ +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. + 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: 'Base branch of the pull request.' + default: ${{ github.event.repository.default_branch }} + latest: + description: >- + Update dependencies to their latest versions, ignoring the ranges + declared in package.json. Set to "false" to update within ranges. + default: 'true' + exclude: + description: >- + Whitespace-separated package name patterns that should not be updated. + Example: "typescript @types/*" + default: '' + update-pnpm: + description: >- + Update pnpm itself (packageManager and devEngines.packageManager) with + `pnpm self-update`. Set to a dist-tag or exact version, or "false" to skip. + default: 'latest' + node: + description: >- + Update the Node.js version pinned in devEngines.runtime to the latest + release of this major version, e.g. "24". Empty 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: Update dependencies + shell: bash + env: + LATEST: ${{ inputs.latest }} + EXCLUDE: ${{ inputs.exclude }} + UPDATE_PNPM: ${{ inputs.update-pnpm }} + NODE_MAJOR: ${{ inputs.node }} + run: | + set -euo pipefail + # Keep patterns like "@types/*" from glob-expanding against the repo. + set -f + + args=(--recursive) + if [ "$LATEST" = "true" ]; then + args+=(--latest) + fi + for pattern in $EXCLUDE; do + args+=("!$pattern") + done + pnpm update "${args[@]}" + + if [ -n "$NODE_MAJOR" ]; then + pnpm runtime set node "$NODE_MAJOR" + fi + + # Last, so every earlier step runs on the pnpm the workflow installed. + if [ "$UPDATE_PNPM" != "false" ]; then + pnpm self-update "$UPDATE_PNPM" + fi + + - 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 }} + 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 config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -B "$BRANCH" + git add -A + git commit -m "$COMMIT_MESSAGE" + # Push with an explicit single-use URL so the provided token is used + # even when the checkout persisted different (or no) credentials. + git push --force "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$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 From c47e1614bdfeb3d06851cbdb001cc012555d1691 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Tue, 21 Jul 2026 13:15:39 +0200 Subject: [PATCH 02/13] fix: base updates on the base branch, keep the token off the command line --- README.md | 2 +- action.yml | 32 +++++++++++++++++++++++++------- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7db56a9..1bde560 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ jobs: |---|---|---| | `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. | | `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 | Base branch of the pull request. | +| `base` | repository default branch | Branch the updates are based on and the pull request targets. | | `latest` | `true` | Update to the latest versions, ignoring `package.json` ranges. Set to `false` to update within ranges. | | `exclude` | — | Whitespace-separated package name patterns that should not be updated, e.g. `typescript @types/*`. | | `update-pnpm` | `latest` | Bump pnpm itself via `pnpm self-update`. A dist-tag or exact version, or `false` to skip. | diff --git a/action.yml b/action.yml index dfdce4d..ec6500e 100644 --- a/action.yml +++ b/action.yml @@ -16,7 +16,7 @@ inputs: most one update PR stays open at a time. default: 'chore/update-dependencies' base: - description: 'Base branch of the pull request.' + description: 'Branch the updates are based on and the pull request targets.' default: ${{ github.event.repository.default_branch }} latest: description: >- @@ -55,6 +55,20 @@ inputs: 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: @@ -107,14 +121,18 @@ runs: exit 0 fi - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git checkout -B "$BRANCH" git add -A git commit -m "$COMMIT_MESSAGE" - # Push with an explicit single-use URL so the provided token is used - # even when the checkout persisted different (or no) credentials. - git push --force "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$BRANCH" + + # 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.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. From 1ecbc4f823b88bc2c59241daad5e223919474265 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Tue, 21 Jul 2026 13:22:29 +0200 Subject: [PATCH 03/13] feat: update pnpm and the pinned runtime by default, within their current majors --- README.md | 12 ++++++------ action.yml | 44 +++++++++++++++++++++++++++++++++++--------- 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 1bde560..37a2289 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # pnpm/update -Updates the dependencies of your project with `pnpm update`, optionally bumps -the pinned pnpm (`packageManager` / `devEngines.packageManager`) and Node.js -(`devEngines.runtime`) versions, and opens a pull request with the result. +Updates the dependencies of your project with `pnpm update`, 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. Unlike external dependency bots, this action runs pnpm itself, so it supports every feature of your workspace: catalogs, patched dependencies, config @@ -40,7 +41,6 @@ jobs: - uses: pnpm/setup@v1 - uses: pnpm/update@v0 with: - node: 24 verify: | pnpm build pnpm test @@ -57,8 +57,8 @@ jobs: | `base` | repository default branch | Branch the updates are based on and the pull request targets. | | `latest` | `true` | Update to the latest versions, ignoring `package.json` ranges. Set to `false` to update within ranges. | | `exclude` | — | Whitespace-separated package name patterns that should not be updated, e.g. `typescript @types/*`. | -| `update-pnpm` | `latest` | Bump pnpm itself via `pnpm self-update`. A dist-tag or exact version, or `false` to skip. | -| `node` | — | Bump the Node.js version pinned in `devEngines.runtime` to the latest release of this major, e.g. `24`. Empty to skip. | +| `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. | diff --git a/action.yml b/action.yml index ec6500e..4a2ea2e 100644 --- a/action.yml +++ b/action.yml @@ -30,13 +30,19 @@ inputs: default: '' update-pnpm: description: >- - Update pnpm itself (packageManager and devEngines.packageManager) with - `pnpm self-update`. Set to a dist-tag or exact version, or "false" to skip. - default: 'latest' + 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: >- - Update the Node.js version pinned in devEngines.runtime to the latest - release of this major version, e.g. "24". Empty to skip. + 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: >- @@ -75,7 +81,7 @@ runs: LATEST: ${{ inputs.latest }} EXCLUDE: ${{ inputs.exclude }} UPDATE_PNPM: ${{ inputs.update-pnpm }} - NODE_MAJOR: ${{ inputs.node }} + NODE: ${{ inputs.node }} run: | set -euo pipefail # Keep patterns like "@types/*" from glob-expanding against the repo. @@ -90,13 +96,33 @@ runs: done pnpm update "${args[@]}" - if [ -n "$NODE_MAJOR" ]; then - pnpm runtime set node "$NODE_MAJOR" + if [ "$NODE" != "false" ]; then + if [ -n "$NODE" ]; then + pnpm runtime set node "$NODE" + else + # Stay on the pinned major and only refresh within it: crossing + # toolchain majors usually needs coordinated changes (Dockerfiles, + # CI matrices, @types/node) that this job cannot make. + PINNED="$(jq -r '.devEngines.runtime // empty + | if type == "array" then .[] else . end + | select(.name == "node") | .version // empty' package.json | head -n 1 || true)" + if [ -n "$PINNED" ]; then + pnpm runtime set node "$(printf '%s' "$PINNED" | grep -oE '[0-9]+' | head -n 1)" + else + echo "No Node.js version pinned in devEngines.runtime; skipping the runtime update." + fi + fi fi # Last, so every earlier step runs on the pnpm the workflow installed. if [ "$UPDATE_PNPM" != "false" ]; then - pnpm self-update "$UPDATE_PNPM" + if [ -n "$UPDATE_PNPM" ]; then + pnpm self-update "$UPDATE_PNPM" + else + # A major bump of pnpm can rewrite the whole lockfile; keep that + # out of routine update PRs by staying on the pinned major. + pnpm self-update "$(pnpm --version | cut -d . -f 1)" + fi fi - name: Verify the updated project From 9e5973c927522199bffc3f347087887178223e94 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Tue, 21 Jul 2026 13:33:56 +0200 Subject: [PATCH 04/13] fix: unset persisted credentials on GitHub Enterprise Server too --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 4a2ea2e..3408894 100644 --- a/action.yml +++ b/action.yml @@ -153,7 +153,7 @@ runs: # 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.https://github.com/.extraheader || true + 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= \ From d8cc10cd755e877e6c0085a6d1bf6068722c8b7e Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Tue, 21 Jul 2026 13:50:11 +0200 Subject: [PATCH 05/13] feat: refresh the lockfile by default, add update-deps and post-update inputs --- README.md | 27 +++++++++++++++++--- action.yml | 73 +++++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 82 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 37a2289..366cda9 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ # pnpm/update -Updates the dependencies of your project with `pnpm update`, keeps the pinned +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. +versions — and opens a pull request with the result. By default the lockfile +is regenerated from scratch, so transitive dependencies of unchanged packages +are refreshed too. Unlike external dependency bots, this action runs pnpm itself, so it supports every feature of your workspace: catalogs, patched dependencies, config @@ -48,6 +50,21 @@ jobs: [`pnpm/setup`]: https://github.com/pnpm/setup +## 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 }} +``` + ## Inputs | Input | Default | Description | @@ -55,8 +72,10 @@ jobs: | `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. | | `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. | -| `latest` | `true` | Update to the latest versions, ignoring `package.json` ranges. Set to `false` to update within ranges. | -| `exclude` | — | Whitespace-separated package name patterns that should not be updated, e.g. `typescript @types/*`. | +| `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. | +| `post-update` | — | Shell commands run after the updates, before verification; their changes are included in the PR. | | `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. | diff --git a/action.yml b/action.yml index 3408894..2f875d1 100644 --- a/action.yml +++ b/action.yml @@ -18,15 +18,33 @@ inputs: base: description: 'Branch the updates are based on and the pull request targets.' default: ${{ github.event.repository.default_branch }} - latest: + update-deps: description: >- - Update dependencies to their latest versions, ignoring the ranges - declared in package.json. Set to "false" to update within ranges. + 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 that should not be updated. - Example: "typescript @types/*" + 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: '' + 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: '' update-pnpm: description: >- @@ -78,7 +96,8 @@ runs: - name: Update dependencies shell: bash env: - LATEST: ${{ inputs.latest }} + UPDATE_DEPS: ${{ inputs.update-deps }} + REFRESH_LOCKFILE: ${{ inputs.refresh-lockfile }} EXCLUDE: ${{ inputs.exclude }} UPDATE_PNPM: ${{ inputs.update-pnpm }} NODE: ${{ inputs.node }} @@ -87,15 +106,16 @@ runs: # Keep patterns like "@types/*" from glob-expanding against the repo. set -f - args=(--recursive) - if [ "$LATEST" = "true" ]; then - args+=(--latest) - fi - for pattern in $EXCLUDE; do - args+=("!$pattern") - done - pnpm update "${args[@]}" + case "$UPDATE_DEPS" in + latest|ranges|false) ;; + *) + echo "::error::Invalid value for the update-deps input: ${UPDATE_DEPS}. Expected latest, ranges, or false." + exit 1 + ;; + esac + # Update the runtime pin first, so the installs below run with it in + # place and sync anything derived from it. if [ "$NODE" != "false" ]; then if [ -n "$NODE" ]; then pnpm runtime set node "$NODE" @@ -114,6 +134,26 @@ runs: fi fi + if [ "$REFRESH_LOCKFILE" = "true" ]; then + # Remove node_modules too so pnpm cannot reuse the hidden lockfile + # in node_modules/.pnpm as the missing wanted lockfile and skip + # resolution. + rm -rf node_modules pnpm-lock.yaml + fi + + if [ "$UPDATE_DEPS" = "false" ]; then + pnpm install + else + args=(--recursive) + if [ "$UPDATE_DEPS" = "latest" ]; then + args+=(--latest) + fi + for pattern in $EXCLUDE; do + args+=("!$pattern") + done + pnpm update "${args[@]}" + fi + # Last, so every earlier step runs on the pnpm the workflow installed. if [ "$UPDATE_PNPM" != "false" ]; then if [ -n "$UPDATE_PNPM" ]; then @@ -125,6 +165,11 @@ runs: fi fi + - 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 From 70533cfb201e272058d2c3c457115f148287a5d2 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Tue, 21 Jul 2026 13:56:39 +0200 Subject: [PATCH 06/13] feat: generate changesets for packages whose production dependencies changed --- README.md | 1 + action.yml | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/README.md b/README.md index 366cda9..93ed57c 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ prereleases while propagating updated versions into other files): | `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. | | `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 declaring a patch bump for every package whose production dependencies changed, so the next release ships the updates. Private, ignored, and dev-dependency-only changes are skipped. Set to `false` to disable. Note: updates that only change `catalog:` entries in `pnpm-workspace.yaml` are not yet detected. | | `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. | diff --git a/action.yml b/action.yml index 2f875d1..d08ec80 100644 --- a/action.yml +++ b/action.yml @@ -46,6 +46,14 @@ inputs: commit. Useful for propagating updated versions into other files; their changes are included in the pull request. default: '' + changesets: + description: >- + Generate a changeset declaring a patch bump for every package whose + production dependencies (dependencies or optionalDependencies) changed, + so the next release ships the updates. Only applies when the repository + uses changesets (.changeset/config.json exists). Set to "false" to + disable. + default: 'true' update-pnpm: description: >- How to update the pinned pnpm version (packageManager and @@ -170,6 +178,52 @@ runs: shell: bash run: ${{ inputs.post-update }} + - name: Generate a changeset + if: ${{ inputs.changesets == 'true' }} + shell: bash + run: | + set -euo pipefail + + # Only for repositories that use changesets. + [ -f .changeset/config.json ] || exit 0 + + # A changeset referencing a package the config ignores would make + # `changeset version` fail. + IGNORED="$(jq -r '(.ignore // [])[]' .changeset/config.json)" + + # Packages whose production dependencies changed must be released + # for the updates to reach their consumers. devDependencies don't + # affect the published artifact, so they don't warrant a release. + PACKAGES="" + while IFS= read -r file; do + [ -n "$file" ] && [ -f "$file" ] || continue + BEFORE="$(git show "HEAD:$file" 2>/dev/null \ + | jq -S '{d: .dependencies, o: .optionalDependencies}' 2>/dev/null || true)" + AFTER="$(jq -S '{d: .dependencies, o: .optionalDependencies}' "$file")" + [ "$BEFORE" = "$AFTER" ] && continue + NAME="$(jq -r 'if .private == true then empty else .name // empty end' "$file")" + [ -n "$NAME" ] || continue + if printf '%s\n' "$IGNORED" | grep -qxF "$NAME"; then continue; fi + PACKAGES="${PACKAGES}${NAME}"$'\n' + done <<< "$(git diff --name-only -- '*package.json')" + + [ -n "$PACKAGES" ] || exit 0 + + # Dated file name: a fixed name could overwrite a changeset from an + # earlier update PR that merged but has not been released yet. + FILE=".changeset/pnpm-update-$(date +%Y-%m-%d).md" + { + echo '---' + printf '%s' "$PACKAGES" | while IFS= read -r name; do + echo "\"$name\": patch" + done + echo '---' + echo + echo 'Update dependencies.' + } > "$FILE" + echo "Created $FILE for:" + printf '%s' "$PACKAGES" + - name: Verify the updated project if: ${{ inputs.verify != '' }} shell: bash From eac38c2846eee9a8708c72fbd0c5776fe8971499 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Thu, 23 Jul 2026 10:26:44 +0200 Subject: [PATCH 07/13] feat: update GitHub Actions too, with pnpm update --include-github-actions --- README.md | 31 +++++++++++++++++++++++++++---- action.yml | 22 ++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 93ed57c..fedf958 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,10 @@ 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. By default the lockfile -is regenerated from scratch, so transitive dependencies of unchanged packages -are refreshed too. +versions — and opens a pull request with the result. It also updates the +GitHub Actions pinned in your workflow files. By default the lockfile is +regenerated from scratch, so transitive dependencies of unchanged packages are +refreshed too. Unlike external dependency bots, this action runs pnpm itself, so it supports every feature of your workspace: catalogs, patched dependencies, config @@ -50,6 +51,27 @@ jobs: [`pnpm/setup`]: https://github.com/pnpm/setup +## Updating GitHub Actions + +Alongside your dependencies, the action bumps the GitHub Actions pinned in +`.github/workflows/*.yml` and `action.yml` (via +`pnpm update --include-github-actions`). This is on by default; set +`github-actions: false` to turn it off. + +GitHub does not let the default `GITHUB_TOKEN` push changes to workflow files, +so when this is enabled you must pass a `token` that carries the `workflow` +scope (a PAT) or `workflows: write` (a GitHub App). Otherwise the push fails as +soon as an action needs updating: + +```yaml + - uses: pnpm/update@v0 + with: + 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` @@ -69,12 +91,13 @@ prereleases while propagating updated versions into other files): | 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. | +| `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` | `true` | 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). Set to `false` to disable. | | `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 declaring a patch bump for every package whose production dependencies changed, so the next release ships the updates. Private, ignored, and dev-dependency-only changes are skipped. Set to `false` to disable. Note: updates that only change `catalog:` entries in `pnpm-workspace.yaml` are not yet detected. | | `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. | diff --git a/action.yml b/action.yml index d08ec80..b24e879 100644 --- a/action.yml +++ b/action.yml @@ -9,6 +9,9 @@ inputs: 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: >- @@ -40,6 +43,15 @@ inputs: refresh-lockfile, excluded packages are still re-resolved within their kept ranges. default: '' + github-actions: + description: >- + Also update the GitHub Actions pinned in .github/workflows/*.yml and + action.yml (via `pnpm update --include-github-actions`). Only applies + when update-deps is "latest" or "ranges". Pushing workflow-file changes + requires a token with the `workflow` scope (a PAT) or `workflows: write` + (a GitHub App) — the default GITHUB_TOKEN cannot. Set to "false" to + disable. + default: 'true' post-update: description: >- Shell commands run after the updates, before verification and the @@ -107,6 +119,8 @@ runs: 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 }} UPDATE_PNPM: ${{ inputs.update-pnpm }} NODE: ${{ inputs.node }} run: | @@ -151,11 +165,19 @@ runs: if [ "$UPDATE_DEPS" = "false" ]; then pnpm install + if [ "$INCLUDE_GITHUB_ACTIONS" = "true" ]; then + # `--include-github-actions` is a `pnpm update` flag; the install + # path above never reaches it, so there's nothing to update here. + echo "::notice::github-actions updates are skipped because update-deps is \"false\" (they need a dependency update pass)." + fi else args=(--recursive) if [ "$UPDATE_DEPS" = "latest" ]; then args+=(--latest) fi + if [ "$INCLUDE_GITHUB_ACTIONS" = "true" ]; then + args+=(--include-github-actions) + fi for pattern in $EXCLUDE; do args+=("!$pattern") done From d814d12e02852bbce6283a46705e09df3762e15b Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Thu, 23 Jul 2026 10:39:32 +0200 Subject: [PATCH 08/13] refactor: make GitHub Actions updates opt-in (default off) --- README.md | 30 ++++++++++++++++-------------- action.yml | 15 ++++++++------- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index fedf958..4738d28 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,14 @@ 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 also updates the -GitHub Actions pinned in your workflow files. By default the lockfile is -regenerated from scratch, so transitive dependencies of unchanged packages are -refreshed too. +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 @@ -53,19 +57,17 @@ jobs: ## Updating GitHub Actions -Alongside your dependencies, the action bumps the GitHub Actions pinned in -`.github/workflows/*.yml` and `action.yml` (via -`pnpm update --include-github-actions`). This is on by default; set -`github-actions: false` to turn it off. - -GitHub does not let the default `GITHUB_TOKEN` push changes to workflow files, -so when this is enabled you must pass a `token` that carries the `workflow` -scope (a PAT) or `workflows: write` (a GitHub App). Otherwise the push fails as -soon as an action needs updating: +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` ``` @@ -97,7 +99,7 @@ prereleases while propagating updated versions into other files): | `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` | `true` | 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). Set to `false` to disable. | +| `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 declaring a patch bump for every package whose production dependencies changed, so the next release ships the updates. Private, ignored, and dev-dependency-only changes are skipped. Set to `false` to disable. Note: updates that only change `catalog:` entries in `pnpm-workspace.yaml` are not yet detected. | | `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. | diff --git a/action.yml b/action.yml index b24e879..911a87f 100644 --- a/action.yml +++ b/action.yml @@ -45,13 +45,14 @@ inputs: default: '' github-actions: description: >- - Also update the GitHub Actions pinned in .github/workflows/*.yml and - action.yml (via `pnpm update --include-github-actions`). Only applies - when update-deps is "latest" or "ranges". Pushing workflow-file changes - requires a token with the `workflow` scope (a PAT) or `workflows: write` - (a GitHub App) — the default GITHUB_TOKEN cannot. Set to "false" to - disable. - default: 'true' + 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 From d9dc264a2341b7d62bb540bcc656dcc8fc4bd66b Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Thu, 23 Jul 2026 10:53:54 +0200 Subject: [PATCH 09/13] test: extract pure logic into scripts/lib.sh with bats unit tests + CI --- .github/workflows/test.yml | 24 ++++++++++ README.md | 14 ++++++ action.yml | 29 +++--------- scripts/lib.sh | 50 ++++++++++++++++++++ test/lib.bats | 95 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 190 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 scripts/lib.sh create mode 100644 test/lib.bats diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e011d37 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,24 @@ +name: Test + +on: + push: + branches: [main] + pull_request: {} + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + # ubuntu runners ship shellcheck; lint the sourceable library (the inline + # action steps carry unavoidable ${{ }} expression noise, so only the + # real bash scripts are checked here). + - name: Shellcheck the scripts + run: shellcheck scripts/*.sh + - name: Install bats + run: npm install -g bats + - name: Run unit tests + run: bats test/ diff --git a/README.md b/README.md index 4738d28..6040c7c 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,20 @@ prereleases while propagating updated versions into other files): token: ${{ secrets.UPDATE_TOKEN }} ``` +## Development + +The action's pure logic (update-argument construction, `update-deps` +validation, and the Node.js-major extraction) lives in `scripts/lib.sh`, which +the action sources at runtime and which is unit-tested with +[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 | diff --git a/action.yml b/action.yml index 911a87f..9367dd8 100644 --- a/action.yml +++ b/action.yml @@ -128,14 +128,10 @@ runs: set -euo pipefail # Keep patterns like "@types/*" from glob-expanding against the repo. set -f + # shellcheck source=scripts/lib.sh + source "$GITHUB_ACTION_PATH/scripts/lib.sh" - case "$UPDATE_DEPS" in - latest|ranges|false) ;; - *) - echo "::error::Invalid value for the update-deps input: ${UPDATE_DEPS}. Expected latest, ranges, or false." - exit 1 - ;; - esac + validate_update_deps "$UPDATE_DEPS" || exit 1 # Update the runtime pin first, so the installs below run with it in # place and sync anything derived from it. @@ -146,11 +142,9 @@ runs: # Stay on the pinned major and only refresh within it: crossing # toolchain majors usually needs coordinated changes (Dockerfiles, # CI matrices, @types/node) that this job cannot make. - PINNED="$(jq -r '.devEngines.runtime // empty - | if type == "array" then .[] else . end - | select(.name == "node") | .version // empty' package.json | head -n 1 || true)" - if [ -n "$PINNED" ]; then - pnpm runtime set node "$(printf '%s' "$PINNED" | grep -oE '[0-9]+' | head -n 1)" + NODE_MAJOR="$(node_major_from_manifest package.json)" + if [ -n "$NODE_MAJOR" ]; then + pnpm runtime set node "$NODE_MAJOR" else echo "No Node.js version pinned in devEngines.runtime; skipping the runtime update." fi @@ -172,16 +166,7 @@ runs: echo "::notice::github-actions updates are skipped because update-deps is \"false\" (they need a dependency update pass)." fi else - args=(--recursive) - if [ "$UPDATE_DEPS" = "latest" ]; then - args+=(--latest) - fi - if [ "$INCLUDE_GITHUB_ACTIONS" = "true" ]; then - args+=(--include-github-actions) - fi - for pattern in $EXCLUDE; do - args+=("!$pattern") - done + mapfile -t args < <(pnpm_update_args "$UPDATE_DEPS" "$INCLUDE_GITHUB_ACTIONS" "$EXCLUDE") pnpm update "${args[@]}" fi diff --git a/scripts/lib.sh b/scripts/lib.sh new file mode 100644 index 0000000..02073d5 --- /dev/null +++ b/scripts/lib.sh @@ -0,0 +1,50 @@ +#!/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) +# 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" pattern + printf '%s\n' --recursive + [ "$update_deps" = latest ] && printf '%s\n' --latest + [ "$include_actions" = true ] && printf '%s\n' --include-github-actions + # 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 +} diff --git a/test/lib.bats b/test/lib.bats new file mode 100644 index 0000000..0bc77d7 --- /dev/null +++ b/test/lib.bats @@ -0,0 +1,95 @@ +#!/usr/bin/env bats +# Unit tests for scripts/lib.sh. Run with `bats test`. + +setup() { + load '../scripts/lib.sh' + TMP="$(mktemp -d)" +} + +teardown() { + rm -rf "$TMP" +} + +# --- validate_update_deps ------------------------------------------------- + +@test "validate_update_deps accepts latest, ranges, and false" { + for value in latest ranges false; do + run validate_update_deps "$value" + [ "$status" -eq 0 ] + [ -z "$output" ] + done +} + +@test "validate_update_deps rejects an unknown value with an error annotation" { + run validate_update_deps latests + [ "$status" -ne 0 ] + [[ "$output" == *"::error::"* ]] + [[ "$output" == *"latests"* ]] +} + +# --- pnpm_update_args ----------------------------------------------------- + +@test "pnpm_update_args: latest adds --recursive and --latest" { + run pnpm_update_args latest false '' + [ "$status" -eq 0 ] + [ "${lines[0]}" = '--recursive' ] + [ "${lines[1]}" = '--latest' ] + [ "${#lines[@]}" -eq 2 ] +} + +@test "pnpm_update_args: ranges omits --latest" { + run pnpm_update_args ranges false '' + [ "${lines[0]}" = '--recursive' ] + [ "${#lines[@]}" -eq 1 ] +} + +@test "pnpm_update_args: include-github-actions adds the flag" { + run pnpm_update_args latest true '' + [[ "$output" == *'--include-github-actions'* ]] +} + +@test "pnpm_update_args: exclude patterns become negation selectors" { + set -f + run pnpm_update_args latest false 'webpack @types/*' + [ "${lines[2]}" = '!webpack' ] + [ "${lines[3]}" = '!@types/*' ] +} + +@test "pnpm_update_args: glob patterns are not expanded against the tree" { + # A path that "@types/*" would match if globbing were active. + mkdir -p "$TMP/@types/node" && touch "$TMP/@types/node/x" + cd "$TMP" + set -f + run pnpm_update_args ranges false '@types/*' + # ranges => no --latest, so the pattern is the only arg after --recursive, + # and it stays literal (a glob would have produced !@types/node instead). + [ "${lines[1]}" = '!@types/*' ] + [ "${#lines[@]}" -eq 2 ] +} + +# --- node_major_from_manifest --------------------------------------------- + +@test "node_major_from_manifest: object form" { + printf '%s' '{"devEngines":{"runtime":{"name":"node","version":"^24.4.0"}}}' > "$TMP/package.json" + run node_major_from_manifest "$TMP/package.json" + [ "$output" = '24' ] +} + +@test "node_major_from_manifest: array form picks the node entry" { + printf '%s' '{"devEngines":{"runtime":[{"name":"bun","version":"1.2"},{"name":"node","version":"26.5.0"}]}}' > "$TMP/package.json" + run node_major_from_manifest "$TMP/package.json" + [ "$output" = '26' ] +} + +@test "node_major_from_manifest: no runtime pinned prints nothing" { + printf '%s' '{"name":"x"}' > "$TMP/package.json" + run node_major_from_manifest "$TMP/package.json" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "node_major_from_manifest: missing file prints nothing and succeeds" { + run node_major_from_manifest "$TMP/does-not-exist.json" + [ "$status" -eq 0 ] + [ -z "$output" ] +} From cb81a1cf5d0f4f76255655b22ece8a5fea703a32 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Thu, 23 Jul 2026 11:02:39 +0200 Subject: [PATCH 10/13] feat: generate changesets via native pnpm update --changeset --- README.md | 2 +- action.yml | 75 ++++++++++++++++---------------------------------- scripts/lib.sh | 4 ++- test/lib.bats | 15 ++++++++++ 4 files changed, 42 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 6040c7c..d638eea 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ CI runs both on every push and pull request. | `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 declaring a patch bump for every package whose production dependencies changed, so the next release ships the updates. Private, ignored, and dev-dependency-only changes are skipped. Set to `false` to disable. Note: updates that only change `catalog:` entries in `pnpm-workspace.yaml` are not yet detected. | +| `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. | diff --git a/action.yml b/action.yml index 9367dd8..e5c283b 100644 --- a/action.yml +++ b/action.yml @@ -61,11 +61,14 @@ inputs: default: '' changesets: description: >- - Generate a changeset declaring a patch bump for every package whose - production dependencies (dependencies or optionalDependencies) changed, - so the next release ships the updates. Only applies when the repository - uses changesets (.changeset/config.json exists). Set to "false" to - disable. + 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: >- @@ -122,6 +125,7 @@ runs: 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: | @@ -166,7 +170,20 @@ runs: echo "::notice::github-actions updates are skipped because update-deps is \"false\" (they need a dependency update pass)." fi else - mapfile -t args < <(pnpm_update_args "$UPDATE_DEPS" "$INCLUDE_GITHUB_ACTIONS" "$EXCLUDE") + # Let `pnpm update` generate the changeset natively (it also covers + # catalog consumers and peer-dep majors, which a git diff can't). + # `--no-changeset` overrides a repo-level `update.changeset: true`. + CHANGESET_ARG='' + if pnpm update --help 2>/dev/null | grep -q -- '--changeset'; then + if [ "$CHANGESETS" = "true" ]; then + CHANGESET_ARG=--changeset + else + CHANGESET_ARG=--no-changeset + fi + elif [ "$CHANGESETS" = "true" ]; then + echo "::notice::Skipping changeset generation: this pnpm version has no --changeset flag. Upgrade pnpm to enable it." + fi + mapfile -t args < <(pnpm_update_args "$UPDATE_DEPS" "$INCLUDE_GITHUB_ACTIONS" "$EXCLUDE" "$CHANGESET_ARG") pnpm update "${args[@]}" fi @@ -186,52 +203,6 @@ runs: shell: bash run: ${{ inputs.post-update }} - - name: Generate a changeset - if: ${{ inputs.changesets == 'true' }} - shell: bash - run: | - set -euo pipefail - - # Only for repositories that use changesets. - [ -f .changeset/config.json ] || exit 0 - - # A changeset referencing a package the config ignores would make - # `changeset version` fail. - IGNORED="$(jq -r '(.ignore // [])[]' .changeset/config.json)" - - # Packages whose production dependencies changed must be released - # for the updates to reach their consumers. devDependencies don't - # affect the published artifact, so they don't warrant a release. - PACKAGES="" - while IFS= read -r file; do - [ -n "$file" ] && [ -f "$file" ] || continue - BEFORE="$(git show "HEAD:$file" 2>/dev/null \ - | jq -S '{d: .dependencies, o: .optionalDependencies}' 2>/dev/null || true)" - AFTER="$(jq -S '{d: .dependencies, o: .optionalDependencies}' "$file")" - [ "$BEFORE" = "$AFTER" ] && continue - NAME="$(jq -r 'if .private == true then empty else .name // empty end' "$file")" - [ -n "$NAME" ] || continue - if printf '%s\n' "$IGNORED" | grep -qxF "$NAME"; then continue; fi - PACKAGES="${PACKAGES}${NAME}"$'\n' - done <<< "$(git diff --name-only -- '*package.json')" - - [ -n "$PACKAGES" ] || exit 0 - - # Dated file name: a fixed name could overwrite a changeset from an - # earlier update PR that merged but has not been released yet. - FILE=".changeset/pnpm-update-$(date +%Y-%m-%d).md" - { - echo '---' - printf '%s' "$PACKAGES" | while IFS= read -r name; do - echo "\"$name\": patch" - done - echo '---' - echo - echo 'Update dependencies.' - } > "$FILE" - echo "Created $FILE for:" - printf '%s' "$PACKAGES" - - name: Verify the updated project if: ${{ inputs.verify != '' }} shell: bash diff --git a/scripts/lib.sh b/scripts/lib.sh index 02073d5..2847b06 100644 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -20,14 +20,16 @@ validate_update_deps() { # $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" pattern + 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" diff --git a/test/lib.bats b/test/lib.bats index 0bc77d7..1e88f51 100644 --- a/test/lib.bats +++ b/test/lib.bats @@ -67,6 +67,21 @@ teardown() { [ "${#lines[@]}" -eq 2 ] } +@test "pnpm_update_args: changeset arg is appended when set" { + run pnpm_update_args latest false '' '--changeset' + [ "${lines[2]}" = '--changeset' ] +} + +@test "pnpm_update_args: --no-changeset is passed through" { + run pnpm_update_args ranges false '' '--no-changeset' + [ "${lines[1]}" = '--no-changeset' ] +} + +@test "pnpm_update_args: no changeset arg when empty" { + run pnpm_update_args latest true 'webpack' '' + [[ "$output" != *changeset* ]] +} + # --- node_major_from_manifest --------------------------------------------- @test "node_major_from_manifest: object form" { From f7f2d4e433f77e5b07e47922b831e0afb5b70ec2 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Thu, 23 Jul 2026 11:23:11 +0200 Subject: [PATCH 11/13] test: end-to-end tests for the update step against a stubbed pnpm --- README.md | 13 ++++- action.yml | 70 +---------------------- scripts/update.sh | 79 ++++++++++++++++++++++++++ test/stubs/pnpm | 20 +++++++ test/update.bats | 137 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 247 insertions(+), 72 deletions(-) create mode 100755 scripts/update.sh create mode 100755 test/stubs/pnpm create mode 100644 test/update.bats diff --git a/README.md b/README.md index d638eea..ebc1558 100644 --- a/README.md +++ b/README.md @@ -91,9 +91,16 @@ prereleases while propagating updated versions into other files): ## Development -The action's pure logic (update-argument construction, `update-deps` -validation, and the Node.js-major extraction) lives in `scripts/lib.sh`, which -the action sources at runtime and which is unit-tested with +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 diff --git a/action.yml b/action.yml index e5c283b..e5cbfb4 100644 --- a/action.yml +++ b/action.yml @@ -128,75 +128,7 @@ runs: CHANGESETS: ${{ inputs.changesets }} UPDATE_PNPM: ${{ inputs.update-pnpm }} NODE: ${{ inputs.node }} - run: | - set -euo pipefail - # Keep patterns like "@types/*" from glob-expanding against the repo. - set -f - # shellcheck source=scripts/lib.sh - source "$GITHUB_ACTION_PATH/scripts/lib.sh" - - validate_update_deps "$UPDATE_DEPS" || exit 1 - - # Update the runtime pin first, so the installs below run with it in - # place and sync anything derived from it. - if [ "$NODE" != "false" ]; then - if [ -n "$NODE" ]; then - pnpm runtime set node "$NODE" - else - # Stay on the pinned major and only refresh within it: crossing - # toolchain majors usually needs coordinated changes (Dockerfiles, - # CI matrices, @types/node) that this job cannot make. - NODE_MAJOR="$(node_major_from_manifest package.json)" - if [ -n "$NODE_MAJOR" ]; then - pnpm runtime set node "$NODE_MAJOR" - else - echo "No Node.js version pinned in devEngines.runtime; skipping the runtime update." - fi - fi - fi - - if [ "$REFRESH_LOCKFILE" = "true" ]; then - # Remove node_modules too so pnpm cannot reuse the hidden lockfile - # in node_modules/.pnpm as the missing wanted lockfile and skip - # resolution. - rm -rf node_modules pnpm-lock.yaml - fi - - if [ "$UPDATE_DEPS" = "false" ]; then - pnpm install - if [ "$INCLUDE_GITHUB_ACTIONS" = "true" ]; then - # `--include-github-actions` is a `pnpm update` flag; the install - # path above never reaches it, so there's nothing to update here. - echo "::notice::github-actions updates are skipped because update-deps is \"false\" (they need a dependency update pass)." - fi - else - # Let `pnpm update` generate the changeset natively (it also covers - # catalog consumers and peer-dep majors, which a git diff can't). - # `--no-changeset` overrides a repo-level `update.changeset: true`. - CHANGESET_ARG='' - if pnpm update --help 2>/dev/null | grep -q -- '--changeset'; then - if [ "$CHANGESETS" = "true" ]; then - CHANGESET_ARG=--changeset - else - CHANGESET_ARG=--no-changeset - fi - elif [ "$CHANGESETS" = "true" ]; then - echo "::notice::Skipping changeset generation: this pnpm version has no --changeset flag. Upgrade pnpm to enable it." - fi - mapfile -t args < <(pnpm_update_args "$UPDATE_DEPS" "$INCLUDE_GITHUB_ACTIONS" "$EXCLUDE" "$CHANGESET_ARG") - pnpm update "${args[@]}" - fi - - # Last, so every earlier step runs on the pnpm the workflow installed. - if [ "$UPDATE_PNPM" != "false" ]; then - if [ -n "$UPDATE_PNPM" ]; then - pnpm self-update "$UPDATE_PNPM" - else - # A major bump of pnpm can rewrite the whole lockfile; keep that - # out of routine update PRs by staying on the pinned major. - pnpm self-update "$(pnpm --version | cut -d . -f 1)" - fi - fi + run: bash "$GITHUB_ACTION_PATH/scripts/update.sh" - name: Run post-update commands if: ${{ inputs.post-update != '' }} diff --git a/scripts/update.sh b/scripts/update.sh new file mode 100755 index 0000000..b6efa7f --- /dev/null +++ b/scripts/update.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# The "Update dependencies" step: bump the pinned runtime, optionally refresh +# the lockfile, run the update (or a plain install), generate a changeset, and +# self-update pnpm. Inputs arrive as environment variables (set by action.yml). +# Pure decision logic lives in lib.sh; this file is the orchestration, driven in +# tests against a stubbed `pnpm` (see test/update.bats). +# +# shellcheck disable=SC2153 # the UPPER_CASE vars are inputs from the environment +set -euo pipefail +# Keep patterns like "@types/*" from glob-expanding against the repo. +set -f +# shellcheck source=scripts/lib.sh +source "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" + +validate_update_deps "$UPDATE_DEPS" || exit 1 + +# Update the runtime pin first, so the installs below run with it in place and +# sync anything derived from it. +if [ "$NODE" != "false" ]; then + if [ -n "$NODE" ]; then + pnpm runtime set node "$NODE" + else + # Stay on the pinned major and only refresh within it: crossing toolchain + # majors usually needs coordinated changes (Dockerfiles, CI matrices, + # @types/node) that this job cannot make. + NODE_MAJOR="$(node_major_from_manifest package.json)" + if [ -n "$NODE_MAJOR" ]; then + pnpm runtime set node "$NODE_MAJOR" + else + echo "No Node.js version pinned in devEngines.runtime; skipping the runtime update." + fi + fi +fi + +if [ "$REFRESH_LOCKFILE" = "true" ]; then + # Remove node_modules too so pnpm cannot reuse the hidden lockfile in + # node_modules/.pnpm as the missing wanted lockfile and skip resolution. + rm -rf node_modules pnpm-lock.yaml +fi + +if [ "$UPDATE_DEPS" = "false" ]; then + pnpm install + if [ "$INCLUDE_GITHUB_ACTIONS" = "true" ]; then + # `--include-github-actions` is a `pnpm update` flag; the install path above + # never reaches it, so there's nothing to update here. + echo "::notice::github-actions updates are skipped because update-deps is \"false\" (they need a dependency update pass)." + fi +else + # Let `pnpm update` generate the changeset natively (it also covers catalog + # consumers and peer-dep majors, which a git diff can't). `--no-changeset` + # overrides a repo-level `update.changeset: true`. + CHANGESET_ARG='' + if pnpm update --help 2>/dev/null | grep -q -- '--changeset'; then + if [ "$CHANGESETS" = "true" ]; then + CHANGESET_ARG=--changeset + else + CHANGESET_ARG=--no-changeset + fi + elif [ "$CHANGESETS" = "true" ]; then + echo "::notice::Skipping changeset generation: this pnpm version has no --changeset flag. Upgrade pnpm to enable it." + fi + # A while-read loop rather than `mapfile` so this runs on bash 3.2 too. + args=() + while IFS= read -r arg; do + args+=("$arg") + done < <(pnpm_update_args "$UPDATE_DEPS" "$INCLUDE_GITHUB_ACTIONS" "$EXCLUDE" "$CHANGESET_ARG") + pnpm update "${args[@]}" +fi + +# Last, so every earlier step runs on the pnpm the workflow installed. +if [ "$UPDATE_PNPM" != "false" ]; then + if [ -n "$UPDATE_PNPM" ]; then + pnpm self-update "$UPDATE_PNPM" + else + # A major bump of pnpm can rewrite the whole lockfile; keep that out of + # routine update PRs by staying on the pinned major. + pnpm self-update "$(pnpm --version | cut -d . -f 1)" + fi +fi diff --git a/test/stubs/pnpm b/test/stubs/pnpm new file mode 100755 index 0000000..cf33b08 --- /dev/null +++ b/test/stubs/pnpm @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Test stub for `pnpm`: records every invocation to $PNPM_LOG and answers the +# few queries scripts/update.sh makes. Behavior is tuned via env vars: +# STUB_SUPPORTS_CHANGESET "1" (default) => `update --help` lists --changeset +# STUB_PNPM_VERSION version printed by `pnpm --version` (default 11.5.0) +printf '%s\n' "$*" >> "$PNPM_LOG" + +if [ "$1" = "--version" ]; then + echo "${STUB_PNPM_VERSION:-11.5.0}" + exit 0 +fi + +if [ "$1" = "update" ] && [ "$2" = "--help" ]; then + if [ "${STUB_SUPPORTS_CHANGESET:-1}" = "1" ]; then + echo " --changeset Generate a changeset for the updated deps" + fi + exit 0 +fi + +exit 0 diff --git a/test/update.bats b/test/update.bats new file mode 100644 index 0000000..e9e32ef --- /dev/null +++ b/test/update.bats @@ -0,0 +1,137 @@ +#!/usr/bin/env bats +# Integration tests for scripts/update.sh: drive the whole "Update dependencies" +# step against a fixture directory with `pnpm` stubbed, and assert on the +# commands it invokes (recorded in $PNPM_LOG). + +SCRIPT="${BATS_TEST_DIRNAME}/../scripts/update.sh" + +setup() { + TMP="$(mktemp -d)" + # Put the pnpm stub first on PATH. + mkdir -p "$TMP/bin" + cp "${BATS_TEST_DIRNAME}/stubs/pnpm" "$TMP/bin/pnpm" + chmod +x "$TMP/bin/pnpm" + PATH="$TMP/bin:$PATH" + export PNPM_LOG="$TMP/pnpm.log" + : > "$PNPM_LOG" + cd "$TMP" + + # Defaults matching the action's inputs; individual tests override via `export`. + export UPDATE_DEPS=latest + export REFRESH_LOCKFILE=false + export EXCLUDE='' + export INCLUDE_GITHUB_ACTIONS=false + export CHANGESETS=true + export UPDATE_PNPM=false + export NODE='' + printf '%s' '{"name":"fixture","devEngines":{"runtime":{"name":"node","version":"^24.4.0"}}}' > package.json +} + +teardown() { + rm -rf "$TMP" +} + +@test "latest update: pins the runtime major, updates recursively with changeset" { + run bash "$SCRIPT" + [ "$status" -eq 0 ] + grep -Fqx 'runtime set node 24' "$PNPM_LOG" + grep -Fqx 'update --recursive --latest --changeset' "$PNPM_LOG" +} + +@test "invalid update-deps fails" { + export UPDATE_DEPS=bogus + run bash "$SCRIPT" + [ "$status" -ne 0 ] + [[ "$output" == *"::error::"* ]] +} + +@test "update-deps=false runs a plain install, not update" { + export UPDATE_DEPS=false + run bash "$SCRIPT" + [ "$status" -eq 0 ] + grep -Fqx 'install' "$PNPM_LOG" + ! grep -Fq -- '--recursive' "$PNPM_LOG" +} + +@test "update-deps=false with github-actions warns that it is skipped" { + export UPDATE_DEPS=false INCLUDE_GITHUB_ACTIONS=true + run bash "$SCRIPT" + [[ "$output" == *"::notice::"* ]] + [[ "$output" == *"github-actions updates are skipped"* ]] +} + +@test "changesets=false passes --no-changeset" { + export CHANGESETS=false + run bash "$SCRIPT" + grep -Fq -- '--no-changeset' "$PNPM_LOG" +} + +@test "unsupported pnpm skips the changeset flag with a notice" { + export STUB_SUPPORTS_CHANGESET=0 + run bash "$SCRIPT" + [ "$status" -eq 0 ] + ! grep -Fq -- '--changeset' "$PNPM_LOG" + ! grep -Fq -- '--no-changeset' "$PNPM_LOG" + [[ "$output" == *"has no --changeset flag"* ]] +} + +@test "github-actions=true adds --include-github-actions" { + export INCLUDE_GITHUB_ACTIONS=true + run bash "$SCRIPT" + grep -Fq -- '--include-github-actions' "$PNPM_LOG" +} + +@test "exclude patterns become negation selectors" { + export EXCLUDE='webpack @types/*' + run bash "$SCRIPT" + grep -Fqx 'update --recursive --latest --changeset !webpack !@types/*' "$PNPM_LOG" +} + +@test "explicit node version is used verbatim" { + export NODE=22 + run bash "$SCRIPT" + grep -Fqx 'runtime set node 22' "$PNPM_LOG" +} + +@test "node=false skips the runtime update" { + export NODE=false + run bash "$SCRIPT" + ! grep -Fq 'runtime set node' "$PNPM_LOG" +} + +@test "no pinned runtime skips the runtime update with a message" { + printf '%s' '{"name":"fixture"}' > package.json + run bash "$SCRIPT" + [ "$status" -eq 0 ] + ! grep -Fq 'runtime set node' "$PNPM_LOG" + [[ "$output" == *"No Node.js version pinned"* ]] +} + +@test "refresh-lockfile removes the lockfile and node_modules" { + export REFRESH_LOCKFILE=true + touch pnpm-lock.yaml + mkdir -p node_modules/.pnpm + run bash "$SCRIPT" + [ "$status" -eq 0 ] + [ ! -e pnpm-lock.yaml ] + [ ! -e node_modules ] +} + +@test "update-pnpm default stays on the pinned major from pnpm --version" { + export STUB_PNPM_VERSION=11.5.0 + export UPDATE_PNPM='' + run bash "$SCRIPT" + grep -Fqx 'self-update 11' "$PNPM_LOG" +} + +@test "update-pnpm explicit dist-tag is passed through" { + export UPDATE_PNPM=next-12 + run bash "$SCRIPT" + grep -Fqx 'self-update next-12' "$PNPM_LOG" +} + +@test "update-pnpm=false skips self-update" { + export UPDATE_PNPM=false + run bash "$SCRIPT" + ! grep -Fq 'self-update' "$PNPM_LOG" +} From dc7b47b0781ffddceab7f2ff7883c38cd00a7c54 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Thu, 23 Jul 2026 11:35:33 +0200 Subject: [PATCH 12/13] ci: run bats via npx to avoid global-install permission errors --- .github/workflows/test.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e011d37..db2327a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,12 +13,11 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - # ubuntu runners ship shellcheck; lint the sourceable library (the inline - # action steps carry unavoidable ${{ }} expression noise, so only the - # real bash scripts are checked here). + # 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 - - name: Install bats - run: npm install -g bats - - name: Run unit tests - run: bats test/ + # Run bats via npx rather than `npm install -g` (which needs root for the + # global prefix on hosted runners); Node/npx are preinstalled. + - name: Run unit and integration tests + run: npx --yes bats test/ From cab2c6a807d4cd8d1d3d38b9922db782786de7d0 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Thu, 23 Jul 2026 11:38:27 +0200 Subject: [PATCH 13/13] ci: run tests with pnpm's pnx instead of npx --- .github/workflows/test.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index db2327a..7978ba3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,11 +13,14 @@ jobs: 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 - # Run bats via npx rather than `npm install -g` (which needs root for the - # global prefix on hosted runners); Node/npx are preinstalled. + # 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: npx --yes bats test/ + run: pnx bats test/