From 73983972db2482671a0e27e98ff9dd83e75f3aa1 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sat, 3 Oct 2026 22:45:11 +0100 Subject: [PATCH] feat(node): support pnpm workspaces --- .github/workflows/node-pr.yaml | 77 +++++++++++++++--- .github/workflows/renovate-autoupdate.yaml | 90 ++++++++++++++++++---- README.md | 14 ++-- 3 files changed, 152 insertions(+), 29 deletions(-) diff --git a/.github/workflows/node-pr.yaml b/.github/workflows/node-pr.yaml index 911ba9d..a611074 100644 --- a/.github/workflows/node-pr.yaml +++ b/.github/workflows/node-pr.yaml @@ -16,6 +16,16 @@ on: required: false type: string default: "." + workspace: + description: "The project is a pnpm workspace. working-directory then holds pnpm-workspace.yaml and pnpm-lock.yaml, and needs no package.json. Dependencies install once there, and scripts run in each of packages." + required: false + type: boolean + default: false + packages: + description: "With workspace, comma-separated workspace package directories, relative to working-directory." + required: false + type: string + default: "" scripts: description: "Comma-separated package.json scripts to run, in order. A script that does not exist is skipped." required: false @@ -104,7 +114,33 @@ jobs: - name: Detect the package manager id: detect working-directory: ${{ inputs.working-directory }} + env: + WORKSPACE: ${{ inputs.workspace }} + PACKAGES: ${{ inputs.packages }} run: | + if [ "$WORKSPACE" = true ]; then + if [ ! -f pnpm-workspace.yaml ] || [ ! -f pnpm-lock.yaml ]; then + echo "::error::workspace needs pnpm-workspace.yaml and pnpm-lock.yaml in ${{ inputs.working-directory }}." + exit 1 + fi + count=0 + IFS=',' read -r -a packages <<<"$PACKAGES" + for package in "${packages[@]}"; do + package=$(echo "$package" | xargs) + [ -n "$package" ] || continue + if [ ! -f "$package/package.json" ]; then + echo "::error::packages lists $package, but ${{ inputs.working-directory }}/$package/package.json does not exist." + exit 1 + fi + count=$((count + 1)) + done + if [ "$count" -eq 0 ]; then + echo "::error::workspace needs packages: the packages to check." + exit 1 + fi + echo "pm=pnpm" >> "$GITHUB_OUTPUT" + exit 0 + fi if [ ! -f package.json ]; then echo "::error::No package.json in ${{ inputs.working-directory }}." exit 1 @@ -150,24 +186,43 @@ jobs: PM: ${{ steps.detect.outputs.pm }} SCRIPTS: ${{ inputs.scripts }} REQUIRE_TESTS: ${{ inputs.require-tests }} + WORKSPACE: ${{ inputs.workspace }} + PACKAGES: ${{ inputs.packages }} run: | - if [ "$REQUIRE_TESTS" = true ] && ! jq -e '.scripts.test' package.json >/dev/null; then - echo "::error::package.json has no test script." - exit 1 + # A single project is the one package ".". A workspace is each listed package. + packages=(.) + if [ "$WORKSPACE" = true ]; then + IFS=',' read -r -a packages <<<"$PACKAGES" + fi + if [ "$REQUIRE_TESTS" = true ]; then + for package in "${packages[@]}"; do + package=$(echo "$package" | xargs) + [ -n "$package" ] || continue + if ! jq -e '.scripts.test' "$package/package.json" >/dev/null; then + echo "::error::$package/package.json has no test script." + exit 1 + fi + done fi ran=() IFS=',' read -r -a scripts <<<"$SCRIPTS" for script in "${scripts[@]}"; do script=$(echo "$script" | xargs) [ -n "$script" ] || continue - if jq -e --arg s "$script" '.scripts[$s]' package.json >/dev/null; then - echo "::group::$PM run $script" - "$PM" run "$script" - echo "::endgroup::" - ran+=("$script") - else - echo "::notice::No '$script' script, skipping." - fi + for package in "${packages[@]}"; do + package=$(echo "$package" | xargs) + [ -n "$package" ] || continue + where="" + [ "$package" = . ] || where=" ($package)" + if jq -e --arg s "$script" '.scripts[$s]' "$package/package.json" >/dev/null; then + echo "::group::$PM run $script$where" + (cd "$package" && "$PM" run "$script") + echo "::endgroup::" + ran+=("$script$where") + else + echo "::notice::No '$script' script$where, skipping." + fi + done done if [ "${#ran[@]}" -eq 0 ]; then echo "::warning::None of the scripts ($SCRIPTS) exist in package.json, so nothing was checked." diff --git a/.github/workflows/renovate-autoupdate.yaml b/.github/workflows/renovate-autoupdate.yaml index 0b59b21..834e2c7 100644 --- a/.github/workflows/renovate-autoupdate.yaml +++ b/.github/workflows/renovate-autoupdate.yaml @@ -58,6 +58,16 @@ on: required: false type: string default: "lint,typecheck,test,build" + node-workspace: + description: "The Node project is a pnpm workspace. node-working-directory then holds pnpm-workspace.yaml and pnpm-lock.yaml, and needs no package.json. Dependencies install once there, and node-scripts run in each of node-packages." + required: false + type: boolean + default: false + node-packages: + description: "With node-workspace, comma-separated workspace package directories, relative to node-working-directory. Only these are updated and tested." + required: false + type: string + default: "" pnpm-version: description: "pnpm version to use, for a repo whose package.json has no packageManager field. Empty uses the packageManager field, or the newest pnpm." required: false @@ -147,6 +157,8 @@ jobs: USE_NODE: ${{ inputs.node }} GO_DIR: ${{ inputs.go-working-directory }} NODE_DIR: ${{ inputs.node-working-directory }} + NODE_WORKSPACE: ${{ inputs.node-workspace }} + NODE_PACKAGES: ${{ inputs.node-packages }} PNPM_VERSION: ${{ inputs.pnpm-version }} GO_MOD_TIDY: ${{ inputs.go-mod-tidy }} CONFIG_FILE: ${{ runner.temp }}/renovate-autoupdate-config.json @@ -160,11 +172,20 @@ jobs: --argjson use_node "$USE_NODE" \ --arg go_dir "$GO_DIR" \ --arg node_dir "$NODE_DIR" \ + --argjson node_workspace "$NODE_WORKSPACE" \ + --arg node_packages "$NODE_PACKAGES" \ --arg pnpm_version "$PNPM_VERSION" \ --argjson go_tidy "$GO_MOD_TIDY" \ ' # A regex for files directly inside a directory, so only what the tests cover is updated. def prefix($d): (if $d == "." or $d == "" then "" else ($d | ltrimstr("./") | rtrimstr("/")) + "/" end) | gsub("\\."; "\\."); + def pkgprefix($d; $p): prefix($d) + ($p | ltrimstr("./") | rtrimstr("/") | gsub("\\."; "\\.")) + "/"; + # A workspace updates only its listed packages. The root lockfile is rewritten by pnpm with them. + def node_files: + if $node_workspace + then [$node_packages | split(",")[] | gsub("^\\s+|\\s+$"; "") | select(length > 0) | "/^" + pkgprefix($node_dir; .) + "package\\.json$/"] + else ["/^" + prefix($node_dir) + "package\\.json$/"] + end; { platform: "github", repositories: [$repo], @@ -175,7 +196,7 @@ jobs: # Renovate writes the lockfile with its own pnpm. Use the version the tests install with. constraints: (if $pnpm_version != "" then {pnpm: $pnpm_version} else {} end), gomod: {managerFilePatterns: ["/^" + prefix($go_dir) + "go\\.mod$/"]}, - npm: {managerFilePatterns: ["/^" + prefix($node_dir) + "package\\.json$/", "/^" + prefix($node_dir) + "pnpm-workspace\\.yaml$/"]}, + npm: {managerFilePatterns: (node_files + ["/^" + prefix($node_dir) + "pnpm-workspace\\.yaml$/"])}, dependencyDashboard: false, # Branches only: with no dashboard, no PR is ever opened. The workflow opens it after the tests. prCreation: "approval", @@ -303,6 +324,8 @@ jobs: env: GO_DIR: ${{ inputs.go-working-directory }} NODE_DIR: ${{ inputs.node-working-directory }} + NODE_WORKSPACE: ${{ inputs.node-workspace }} + NODE_PACKAGES: ${{ inputs.node-packages }} USE_GO: ${{ inputs.go }} USE_NODE: ${{ inputs.node }} run: | @@ -310,7 +333,29 @@ jobs: node=false pm=npm if [ "$USE_GO" = true ] && [ -f "$GO_DIR/go.mod" ]; then go=true; fi - if [ "$USE_NODE" = true ] && [ -f "$NODE_DIR/package.json" ]; then + if [ "$USE_NODE" = true ] && [ "$NODE_WORKSPACE" = true ]; then + if [ ! -f "$NODE_DIR/pnpm-workspace.yaml" ] || [ ! -f "$NODE_DIR/pnpm-lock.yaml" ]; then + echo "::error::node-workspace needs pnpm-workspace.yaml and pnpm-lock.yaml in $NODE_DIR." + exit 1 + fi + IFS=',' read -r -a packages <<<"$NODE_PACKAGES" + count=0 + for package in "${packages[@]}"; do + package=$(echo "$package" | xargs) + [ -n "$package" ] || continue + if [ ! -f "$NODE_DIR/$package/package.json" ]; then + echo "::error::node-packages lists $package, but $NODE_DIR/$package/package.json does not exist." + exit 1 + fi + count=$((count + 1)) + done + if [ "$count" -eq 0 ]; then + echo "::error::node-workspace needs node-packages: the packages to update and test." + exit 1 + fi + node=true + pm=pnpm + elif [ "$USE_NODE" = true ] && [ -f "$NODE_DIR/package.json" ]; then node=true if [ -f "$NODE_DIR/pnpm-lock.yaml" ]; then pm=pnpm elif [ -f "$NODE_DIR/yarn.lock" ]; then pm=yarn @@ -402,25 +447,44 @@ jobs: PM: ${{ steps.detect.outputs.pm }} SCRIPTS: ${{ inputs.node-scripts }} REQUIRE_TESTS: ${{ inputs.require-node-tests }} + NODE_WORKSPACE: ${{ inputs.node-workspace }} + NODE_PACKAGES: ${{ inputs.node-packages }} run: | set -o pipefail log="$RUNNER_TEMP/test-output.log" - if [ "$REQUIRE_TESTS" = true ] && ! jq -e '.scripts.test' package.json >/dev/null; then - echo "::error::package.json has no test script. Add one, or set require-node-tests to false." - echo "package.json has no test script, so the update was not tested." >>"$log" - exit 1 + # A single project is the one package ".". A workspace is each listed package. + packages=(.) + if [ "$NODE_WORKSPACE" = true ]; then + IFS=',' read -r -a packages <<<"$NODE_PACKAGES" + fi + if [ "$REQUIRE_TESTS" = true ]; then + for package in "${packages[@]}"; do + package=$(echo "$package" | xargs) + [ -n "$package" ] || continue + if ! jq -e '.scripts.test' "$package/package.json" >/dev/null; then + echo "::error::$package/package.json has no test script. Add one, or set require-node-tests to false." + echo "$package/package.json has no test script, so the update was not tested." >>"$log" + exit 1 + fi + done fi IFS=',' read -r -a scripts <<<"$SCRIPTS" for script in "${scripts[@]}"; do script=$(echo "$script" | xargs) [ -n "$script" ] || continue - if jq -e --arg s "$script" '.scripts[$s]' package.json >/dev/null; then - echo "::group::$PM run $script" - "$PM" run "$script" 2>&1 | tee -a "$log" - echo "::endgroup::" - else - echo "::notice::No '$script' script, skipping." - fi + for package in "${packages[@]}"; do + package=$(echo "$package" | xargs) + [ -n "$package" ] || continue + where="" + [ "$package" = . ] || where=" ($package)" + if jq -e --arg s "$script" '.scripts[$s]' "$package/package.json" >/dev/null; then + echo "::group::$PM run $script$where" + (cd "$package" && "$PM" run "$script") 2>&1 | tee -a "$log" + echo "::endgroup::" + else + echo "::notice::No '$script' script$where, skipping." + fi + done done - name: Mark the tests as passed diff --git a/README.md b/README.md index 815575d..fffa547 100644 --- a/README.md +++ b/README.md @@ -117,14 +117,16 @@ jobs: | Input | Default | Description | |-------|---------|-------------| | `node-version` | `22` | Node.js version | -| `working-directory` | `.` | Directory that holds `package.json` | +| `working-directory` | `.` | Directory that holds `package.json`. With `workspace`, the directory that holds `pnpm-workspace.yaml` and `pnpm-lock.yaml`. | +| `workspace` | `false` | pnpm workspace: install once in `working-directory`, then run `scripts` in each of `packages`. `working-directory` needs no `package.json`. | +| `packages` | | With `workspace`, comma-separated package directories relative to `working-directory`, for example `admin,public,packages/ui` | | `scripts` | `lint,typecheck,test,build` | Scripts to run in order. A missing script is skipped. | | `pnpm-version` | | pnpm version for a repo with no `packageManager` field. Empty uses the field, or the newest pnpm. | | `require-tests` | `false` | Fail when there is no `test` script | | `lfs` | `false` | Git LFS checkout | | `runner` | | Runner label or runner group, as in the other workflows | -pnpm is used when `pnpm-lock.yaml` exists, yarn for `yarn.lock`, otherwise npm. pnpm and yarn run through Corepack, so set `packageManager` in `package.json`. +pnpm is used when `pnpm-lock.yaml` exists, yarn for `yarn.lock`, otherwise npm. pnpm and yarn run through Corepack, so set `packageManager` in `package.json`. A workspace with no root `package.json` has no such field, so set `pnpm-version`. With `require-tests`, every listed package needs a `test` script. ### `go-autoupdate.yaml` @@ -184,7 +186,9 @@ The workflow finds `go.mod` and `package.json` itself and tests what exists. A r | `go-test-command` | | Replaces the default `go build` and `go test`, for example `encore test ./...` for an Encore app. | | `node-version` | `22` | Node.js version | | `node` | `true` | Update and test the Node project. Turn off for a repo with no frontend to test. | -| `node-working-directory` | `.` | Directory that holds `package.json`. Only this project is updated and tested. | +| `node-working-directory` | `.` | Directory that holds `package.json`. Only this project is updated and tested. With `node-workspace`, the directory that holds the workspace files. | +| `node-workspace` | `false` | pnpm workspace: `node-working-directory` holds `pnpm-workspace.yaml` and `pnpm-lock.yaml` and needs no `package.json`. Install once there, then run `node-scripts` in each of `node-packages`. | +| `node-packages` | | With `node-workspace`, comma-separated package directories relative to `node-working-directory`, for example `admin,public,packages/ui`. Only these are updated and tested. | | `node-scripts` | `lint,typecheck,test,build` | Scripts to run in order. A missing script is skipped. | | `pnpm-version` | | pnpm version for a repo with no `packageManager` field. Empty uses the field, or the newest pnpm. | | `require-node-tests` | `true` | Fail when there is no `test` script, so an untested update never merges | @@ -198,11 +202,11 @@ The workflow finds `go.mod` and `package.json` itself and tests what exists. A r **Failing updates.** The last 50 KB of the test output goes into a comment on one issue titled "Dependency updates failing tests". Each failing branch has its own comment, updated on every run, and a header with the commit and the run link. When a branch passes and merges, its comment is removed. When no failing branch is left, the issue closes. The output is also in the run summary. Without `issues: write` the run still works, but the issue is skipped. -**Only what is tested is updated.** Renovate manages one Go module and one Node project: the ones in `go-working-directory` and `node-working-directory`. A second `package.json` (docs, e2e tests, another frontend) or a second `go.mod` is left alone, because nothing would test its updates. +**Only what is tested is updated.** Renovate manages one Go module and one Node project: the ones in `go-working-directory` and `node-working-directory`. A second `package.json` (docs, e2e tests, another frontend) or a second `go.mod` is left alone, because nothing would test its updates. In a workspace the same rule applies to packages: only those in `node-packages` are updated, plus `pnpm-workspace.yaml` and the root `pnpm-lock.yaml`. **Branches run one at a time**, so each one is tested on top of the ones merged before it. A branch that conflicts with the default branch is skipped, and Renovate rebases it on the next run. -**Package manager.** pnpm is used when `pnpm-lock.yaml` exists, yarn for `yarn.lock`, otherwise npm. The install step never changes the lockfile (`--frozen-lockfile`, `--immutable`, `npm ci`), so a lockfile that does not match `package.json` fails the run. pnpm and yarn run through Corepack, so set `packageManager` in `package.json`. +**Package manager.** pnpm is used when `pnpm-lock.yaml` exists, yarn for `yarn.lock`, otherwise npm. The install step never changes the lockfile (`--frozen-lockfile`, `--immutable`, `npm ci`), so a lockfile that does not match `package.json` fails the run. pnpm and yarn run through Corepack, so set `packageManager` in `package.json`. A workspace with no root `package.json` has no such field, so set `pnpm-version`. With `require-node-tests`, every listed package needs a `test` script. **Per-project rules.** Put a `renovate.json` in the calling repository. Renovate reads it and merges it with the defaults, so no extra input is needed: