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..cac3e9a47 --- /dev/null +++ b/scripts/validate-lockfile-platforms.js @@ -0,0 +1,111 @@ +#!/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 (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. + +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\//, ''); + + // 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 + + 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); + // 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 }); + } +} + +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, sep, darwinNames } of missing) { + console.error(` ${base}`); + for (const name of darwinNames) { + console.error(` found: ${name}`); + } + console.error(` missing: ${base}${sep}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.` +);