diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..7978ba3 --- /dev/null +++ b/.github/workflows/test.yml @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ebc1558 --- /dev/null +++ b/README.md @@ -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. | diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..e5cbfb4 --- /dev/null +++ b/action.yml @@ -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 }} + 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 diff --git a/scripts/lib.sh b/scripts/lib.sh new file mode 100644 index 0000000..2847b06 --- /dev/null +++ b/scripts/lib.sh @@ -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 +} 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/lib.bats b/test/lib.bats new file mode 100644 index 0000000..1e88f51 --- /dev/null +++ b/test/lib.bats @@ -0,0 +1,110 @@ +#!/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 ] +} + +@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" { + 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" ] +} 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" +}