From 8c06b72ccbbc7d4552684ac893459cde63602128 Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 21 Oct 2026 15:10:36 -0700 Subject: [PATCH] ci: Empty cached node_modules that have broken `.bin` scripts All PR jobs share one Namespace cache volume for their node_modules folders. When the volume holds an isolated layout (from #25368) and a hoisted branch installs on top of it, pnpm 12 removes `node_modules/.pnpm`. It keeps the `.bin` scripts of the workspace packages, though, and they still run targets in `.pnpm`. Build then fails in `@sentry/angular` (`ng`) and `@sentry/nextjs` (`rollup`) with `Cannot find module '.../node_modules/.pnpm/...'`. Neither a second `pnpm install` nor `--force` removes these scripts, and every PR job that passes saves them back to the volume. So re-runs do not help. Before `pnpm install`, `install-dependencies` now empties the mounted node_modules folders when the layout on the volume differs from `nodeLinker`, or when a `.bin` entry points to a file that does not exist. The second check also repairs the volume that is broken now, and pnpm can leave such dead links after normal dependency changes too. The folders are mounted from the volume, so the step empties them instead of removing them. Removing only the broken links is not enough. pnpm does not recreate a missing link when the lockfile is up to date, so a package that needs its own version of a tool would silently run the root version instead. A full install takes about 30 seconds with a warm pnpm store. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../actions/install-dependencies/action.yml | 33 ++++++++++++++++++ .../install-dependencies/find-broken-bin.mjs | 34 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 .github/actions/install-dependencies/find-broken-bin.mjs diff --git a/.github/actions/install-dependencies/action.yml b/.github/actions/install-dependencies/action.yml index 420b26b59d49..27971a484f63 100644 --- a/.github/actions/install-dependencies/action.yml +++ b/.github/actions/install-dependencies/action.yml @@ -23,6 +23,39 @@ runs: - name: Install pnpm uses: ./.github/actions/install-pnpm + # When the node_modules layout changes, `pnpm install` keeps the `.bin` scripts of the workspace packages, which + # then point into a folder that the new layout does not have. Branches that change `nodeLinker` share this cache + # volume, so start from empty node_modules folders when the layout on the volume differs, or when an earlier + # install already left such broken `.bin` scripts behind. + - name: Remove node_modules of another layout + run: | + mapfile -t dirs < <(grep '/node_modules$' <<< "$DEPENDENCY_PATHS") + expected=$(pnpm config get node-linker) + if [[ "$expected" == undefined ]]; then + expected=isolated + fi + actual=$(node -p 'try { JSON.parse(require("fs").readFileSync("node_modules/.modules.yaml", "utf8")).nodeLinker ?? "" } catch { "" }') + if [[ -n "$actual" && "$actual" != "$expected" ]]; then + reason="The node_modules folders use the $actual layout, but nodeLinker is $expected." + else + broken=$(node "$GITHUB_ACTION_PATH/find-broken-bin.mjs" "${dirs[@]}") + if [[ -n "$broken" ]]; then + reason="The target of $broken does not exist." + fi + fi + if [[ -n "$reason" ]]; then + echo "$reason Removing the node_modules folders." + for dir in "${dirs[@]}"; do + if [[ -d "$dir" ]]; then + # The folders are mounted from the cache volume, so empty them instead of removing them + find "$dir/" -mindepth 1 -maxdepth 1 -exec rm -rf {} + + fi + done + fi + env: + DEPENDENCY_PATHS: ${{ steps.paths.outputs.paths }} + shell: bash + # The cache volume can hold node_modules of another commit or branch. This is a no-op when they match # pnpm-lock.yaml. - name: Install dependencies diff --git a/.github/actions/install-dependencies/find-broken-bin.mjs b/.github/actions/install-dependencies/find-broken-bin.mjs new file mode 100644 index 000000000000..24aa8753cac5 --- /dev/null +++ b/.github/actions/install-dependencies/find-broken-bin.mjs @@ -0,0 +1,34 @@ +// Prints the first `.bin` entry in the given node_modules folders whose target does not exist. +import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +// The pnpm shell shim runs the target with `exec "$basedir_abs/"` (`$basedir` in older versions) +const SHIM_TARGET = /^\s*exec \S+\s+"\$basedir(?:_abs)?\/([^"]+)"/m; + +for (const dir of process.argv.slice(2)) { + const binDir = join(dir, '.bin'); + let names; + try { + names = readdirSync(binDir); + } catch { + continue; + } + + for (const name of names) { + const bin = join(binDir, name); + const stat = lstatSync(bin); + let target; + if (stat.isSymbolicLink()) { + target = bin; + } else if (stat.isFile() && stat.size < 64 * 1024) { + const match = readFileSync(bin, 'utf8').match(SHIM_TARGET); + target = match && resolve(binDir, match[1]); + } + + if (target && !existsSync(target)) { + // eslint-disable-next-line no-console + console.log(bin); + process.exit(0); + } + } +}