Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/validate-lockfile.yml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 0 additions & 10 deletions .npmrc
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
111 changes: 111 additions & 0 deletions scripts/validate-lockfile-platforms.js
Original file line number Diff line number Diff line change
@@ -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 [-/]<os>-<cpu>[-<abi>] 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.`
);
Loading