Merge branch 'feat/pnpm-workspace' into feat/node-registry-workspace

This commit is contained in:
2026-10-03 22:45:36 +01:00
3 changed files with 152 additions and 29 deletions
+66 -11
View File
@@ -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
@@ -118,7 +128,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
@@ -179,24 +215,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."
+77 -13
View File
@@ -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
@@ -161,6 +171,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 }}
NPM_SCOPE: ${{ inputs.npm-registry-scope }}
@@ -184,11 +196,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],
@@ -199,7 +220,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",
@@ -328,6 +349,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: |
@@ -335,7 +358,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
@@ -443,25 +488,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
+9 -5
View File
@@ -117,7 +117,9 @@ 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 |
@@ -126,7 +128,7 @@ jobs:
| `npm-registry-url` | | URL of that registry, for example `https://npm.fontawesome.com/`. |
| `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.
**Private npm registry.** Set `npm-registry-scope`, `npm-registry-url` and the `npm-registry-token` secret together; with any one missing nothing changes. The token goes into an npmrc outside the checkout (`NPM_CONFIG_USERCONFIG`), is masked in logs, and works for npm, pnpm and yarn classic. Yarn 2+ ignores npmrc and needs its own `.yarnrc.yml`.
@@ -188,7 +190,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 |
@@ -206,11 +210,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: