From 5019ea29098ba7c883496a6026ce02478da6e4ad Mon Sep 17 00:00:00 2001 From: rocketstack-matt Date: Sat, 28 Feb 2026 18:41:58 +0000 Subject: [PATCH 1/2] fix(ci): remove invalid .npmrc config and add lockfile platform validation The supportedArchitectures keys in .npmrc are pnpm-only and produce "Unknown project config" warnings on every npm CI run. The actual fix for missing native bindings was lockfile regeneration (npm/cli#4828). - Remove pnpm-only supportedArchitectures block from .npmrc - Add scripts/validate-lockfile-platforms.js to verify linux-x64 variants exist for all packages with darwin variants - Add validate-lockfile CI workflow triggered on package-lock.json changes - Document lockfile regeneration process in AGENTS.md --- .github/workflows/validate-lockfile.yml | 35 ++++++++ .npmrc | 10 --- AGENTS.md | 12 +++ scripts/validate-lockfile-platforms.js | 108 ++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/validate-lockfile.yml create mode 100755 scripts/validate-lockfile-platforms.js diff --git a/.github/workflows/validate-lockfile.yml b/.github/workflows/validate-lockfile.yml new file mode 100644 index 000000000..2c9b5b870 --- /dev/null +++ b/.github/workflows/validate-lockfile.yml @@ -0,0 +1,35 @@ +name: Validate Lockfile Platforms + +permissions: + contents: read + +on: + pull_request: + branches: + - 'main' + - 'release*' + paths: + - 'package-lock.json' + push: + branches: + - 'main' + - 'release*' + paths: + - 'package-lock.json' + +jobs: + validate-lockfile: + name: Validate Lockfile Platforms + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Setup Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 + with: + node-version: '22' + + - name: Validate platform bindings in lockfile + run: node scripts/validate-lockfile-platforms.js diff --git a/.npmrc b/.npmrc index fa603469f..3834a3bda 100644 --- a/.npmrc +++ b/.npmrc @@ -2,13 +2,3 @@ # Blocks unsupported versions (e.g. Node 18, 20, 23) but still allows # Node 24+ since engines includes ">=24.10.0" for local development. engine-strict=true - -# Ensure npm resolves optional native bindings for all CI platforms, -# not just the current developer machine. Prevents missing-binding -# errors (e.g. @tailwindcss/oxide) when the lockfile is regenerated -# on macOS but consumed on Linux runners. -supportedArchitectures[os][]=current -supportedArchitectures[os][]=linux -supportedArchitectures[cpu][]=current -supportedArchitectures[cpu][]=x64 -supportedArchitectures[cpu][]=arm64 diff --git a/AGENTS.md b/AGENTS.md index 1bd209e0c..c74eac559 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,18 @@ The `engines` field in `package.json` (`^22.14.0 || >=24.10.0`) also permits Nod - **`@types/node`** is overridden to `^22.0.0` in root `package.json` to prevent transitive dependencies from pulling in a different major version - **Renovate** is configured with `allowedVersions: "<23.0.0"` for `@types/node` +### Lockfile Regeneration + +**CRITICAL**: npm has a known bug ([npm/cli#4828](https://github.com/npm/cli/issues/4828)) where running `npm install` with an existing `node_modules` directory prunes optional platform-specific dependencies (e.g. `@tailwindcss/oxide`, `@swc/core`, `@esbuild`) for platforms other than the current machine. This causes CI failures on Linux runners when the lockfile was regenerated on macOS. + +**Correct method** — always delete both `node_modules` and the lockfile: + +```bash +rm -rf node_modules package-lock.json && npm install +``` + +**Never** regenerate the lockfile without deleting `node_modules` first. The `validate-lockfile` CI workflow checks that all expected platform variants are present in `package-lock.json`. + ### Why this matters Running `npm install` on a different Node major version (e.g. Node 25) causes: diff --git a/scripts/validate-lockfile-platforms.js b/scripts/validate-lockfile-platforms.js new file mode 100755 index 000000000..723c149c8 --- /dev/null +++ b/scripts/validate-lockfile-platforms.js @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +/** + * Validates that package-lock.json contains platform-specific optional + * dependencies for all platforms used in CI. + * + * npm has a known bug (https://github.com/npm/cli/issues/4828) where running + * `npm install` with an existing node_modules directory can prune optional + * platform-specific packages for platforms other than the current machine. + * This causes CI failures on Linux runners when the lockfile was regenerated + * on macOS without first deleting node_modules. + * + * This script catches the problem early by checking that every package with + * darwin variants also has corresponding linux-x64 variants. + */ + +'use strict'; + +const { readFileSync } = require('fs'); +const { join } = require('path'); + +const lockfilePath = join(__dirname, '..', 'package-lock.json'); + +let lockfile; +try { + lockfile = JSON.parse(readFileSync(lockfilePath, 'utf8')); +} catch (err) { + console.error(`Failed to read package-lock.json: ${err.message}`); + process.exit(1); +} + +const packages = lockfile.packages || {}; + +// Collect all platform-specific packages (those with both os and cpu fields) +// and group them by their base package name. +// +// Naming conventions: +// @esbuild/darwin-arm64 -> base: @esbuild +// @rollup/rollup-darwin-arm64 -> base: @rollup/rollup +// @tailwindcss/oxide-darwin-x64 -> base: @tailwindcss/oxide +// lightningcss-darwin-arm64 -> base: lightningcss +// +// We strip the trailing --[-] suffix to derive the base name. + +const osPattern = 'linux|darwin|win32|android|freebsd|openbsd|netbsd|sunos|aix'; +const suffixRegex = new RegExp(`-(?:${osPattern})[-/].*$`); + +const groups = new Map(); + +for (const [key, meta] of Object.entries(packages)) { + if (!meta.os || !meta.cpu) continue; + + // Normalise: strip leading node_modules/ (and nested node_modules/ paths) + const name = key.replace(/^(.+\/)?node_modules\//, ''); + + // Scoped packages like @esbuild/darwin-arm64 have the os in the last + // segment. Unscoped ones like lightningcss-darwin-arm64 have it too. + const base = name.replace(suffixRegex, ''); + if (base === name) continue; // no recognisable platform suffix + + if (!groups.has(base)) groups.set(base, []); + groups.get(base).push({ name, os: meta.os, cpu: meta.cpu }); +} + +// For every group that contains darwin variants, verify that linux-x64 +// variants are also present (our CI runners are linux-x64). + +const missing = []; + +for (const [base, variants] of groups) { + const hasDarwin = variants.some(v => v.os.includes('darwin')); + if (!hasDarwin) continue; + + const hasLinuxX64 = variants.some( + v => v.os.includes('linux') && v.cpu.includes('x64') + ); + + if (!hasLinuxX64) { + const darwinNames = variants + .filter(v => v.os.includes('darwin')) + .map(v => v.name); + missing.push({ base, darwinNames }); + } +} + +if (missing.length > 0) { + console.error('Lockfile platform validation failed!\n'); + console.error( + 'The following packages have darwin variants but are missing linux-x64 variants.\n' + + 'This will cause CI failures on Linux runners.\n' + ); + for (const { base, darwinNames } of missing) { + console.error(` ${base}`); + for (const name of darwinNames) { + console.error(` found: ${name}`); + } + console.error(` missing: ${base}-linux-x64-*\n`); + } + console.error( + 'Fix: regenerate the lockfile from a clean state:\n\n' + + ' rm -rf node_modules package-lock.json && npm install\n' + ); + process.exit(1); +} + +console.log( + `Lockfile platform check passed: ${groups.size} platform-specific package groups validated.` +); From 53421d4f356af7e388c885ddf727013071acecfc Mon Sep 17 00:00:00 2001 From: rocketstack-matt Date: Sat, 28 Feb 2026 18:49:23 +0000 Subject: [PATCH 2/2] fix(ci): handle scoped packages with slash before OS segment The platform-suffix regex only matched names with a dash before the OS segment (e.g. @rollup/rollup-darwin-arm64) but missed scoped packages like @esbuild/darwin-arm64 where the OS follows a slash. Update the regex to match both [-/] separators so all platform-specific package groups are validated (6 groups instead of 5). --- scripts/validate-lockfile-platforms.js | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/scripts/validate-lockfile-platforms.js b/scripts/validate-lockfile-platforms.js index 723c149c8..cac3e9a47 100755 --- a/scripts/validate-lockfile-platforms.js +++ b/scripts/validate-lockfile-platforms.js @@ -35,15 +35,15 @@ const packages = lockfile.packages || {}; // and group them by their base package name. // // Naming conventions: -// @esbuild/darwin-arm64 -> base: @esbuild -// @rollup/rollup-darwin-arm64 -> base: @rollup/rollup -// @tailwindcss/oxide-darwin-x64 -> base: @tailwindcss/oxide -// lightningcss-darwin-arm64 -> base: lightningcss +// @esbuild/darwin-arm64 -> base: @esbuild (os after /) +// @rollup/rollup-darwin-arm64 -> base: @rollup/rollup (os after -) +// @tailwindcss/oxide-darwin-x64 -> base: @tailwindcss/oxide (os after -) +// lightningcss-darwin-arm64 -> base: lightningcss (os after -) // -// We strip the trailing --[-] suffix to derive the base name. +// We strip the trailing [-/]-[-] suffix to derive the base name. const osPattern = 'linux|darwin|win32|android|freebsd|openbsd|netbsd|sunos|aix'; -const suffixRegex = new RegExp(`-(?:${osPattern})[-/].*$`); +const suffixRegex = new RegExp(`[-/](?:${osPattern})[-/].*$`); const groups = new Map(); @@ -53,8 +53,8 @@ for (const [key, meta] of Object.entries(packages)) { // Normalise: strip leading node_modules/ (and nested node_modules/ paths) const name = key.replace(/^(.+\/)?node_modules\//, ''); - // Scoped packages like @esbuild/darwin-arm64 have the os in the last - // segment. Unscoped ones like lightningcss-darwin-arm64 have it too. + // The os segment may follow a dash (@rollup/rollup-darwin-arm64) or a + // slash (@esbuild/darwin-arm64). The regex handles both separators. const base = name.replace(suffixRegex, ''); if (base === name) continue; // no recognisable platform suffix @@ -79,7 +79,10 @@ for (const [base, variants] of groups) { const darwinNames = variants .filter(v => v.os.includes('darwin')) .map(v => v.name); - missing.push({ base, darwinNames }); + // Determine the separator used between base and os + // e.g. @esbuild/darwin-arm64 uses "/" while @rollup/rollup-darwin-arm64 uses "-" + const sep = darwinNames[0].startsWith(base + '/') ? '/' : '-'; + missing.push({ base, sep, darwinNames }); } } @@ -89,12 +92,12 @@ if (missing.length > 0) { 'The following packages have darwin variants but are missing linux-x64 variants.\n' + 'This will cause CI failures on Linux runners.\n' ); - for (const { base, darwinNames } of missing) { + for (const { base, sep, darwinNames } of missing) { console.error(` ${base}`); for (const name of darwinNames) { console.error(` found: ${name}`); } - console.error(` missing: ${base}-linux-x64-*\n`); + console.error(` missing: ${base}${sep}linux-x64-*\n`); } console.error( 'Fix: regenerate the lockfile from a clean state:\n\n' +