diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b36f37e8..d51a3da8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -157,3 +157,43 @@ jobs: with: setup-script: pnpm run build main-script: pnpm run test:npm + + # Repo-owned: every override declares `engines.node: >=24`, but the jobs + # above run a newer current-line Node โ€” nothing executes the FLOOR, so a + # too-new syntax/API in an override stays green here and only breaks in a + # consumer pinned to the floor. This job side-installs the exact floor + # release (sha256-verified against nodejs.org's published SHASUMS256 before + # extraction) and dynamic-imports every override's node entry under it. + floor-node-smoke: + name: ๐Ÿงช NPM Overrides on Floor Node + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Bootstrap checkout + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + SERVER_URL: ${{ github.server_url }} + REPOSITORY: ${{ github.repository }} + TRIGGER_REF: ${{ github.ref }} + run: | + set -euo pipefail + git init -q + git config --local advice.detachedHead false + git remote remove origin 2>/dev/null || true + git remote add origin "${SERVER_URL}/${REPOSITORY}" + FETCH_ARGS=(--no-tags --prune --depth 1 origin "${TRIGGER_REF}") + if [ -n "${GITHUB_TOKEN}" ]; then + AUTH_B64="$(printf 'x-access-token:%s' "${GITHUB_TOKEN}" | base64 | tr -d '\n')" + git -c "http.${SERVER_URL}/.extraheader=AUTHORIZATION: basic ${AUTH_B64}" fetch "${FETCH_ARGS[@]}" + else + git fetch "${FETCH_ARGS[@]}" + fi + git checkout -q --detach FETCH_HEAD + - uses: ./.github/actions/fleet/setup-and-install + with: + node-version: 24 + socket-api-token: ${{ secrets.SOCKET_API_TOKEN }} + - uses: ./.github/actions/fleet/run-script + with: + main-script: pnpm run test:npm:floor diff --git a/package.json b/package.json index 5938732ca..9df4f1faa 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "test": "node scripts/fleet/test.mts", "test:fuzz": "node scripts/repo/fuzz.mts", "test:npm": "node scripts/npm/run-vitest-npm.mts", + "test:npm:floor": "node scripts/npm/floor-node-smoke.mts", "type": "node node_modules/typescript/bin/tsc --noEmit -p .config/fleet/tsconfig.check.json", "update": "node scripts/fleet/update.mts", "update:external-tools": "node scripts/repo/update-external-tools.mts", diff --git a/scripts/npm/floor-node-smoke.mts b/scripts/npm/floor-node-smoke.mts new file mode 100644 index 000000000..f703367de --- /dev/null +++ b/scripts/npm/floor-node-smoke.mts @@ -0,0 +1,216 @@ +#!/usr/bin/env node +/** + * @file Prove every npm override runs on the FLOOR Node its engines declare. + * All packages/npm overrides ship `engines.node: >=24`, but regular CI runs + * a newer current-line Node โ€” nothing executes the floor, so an override + * that quietly uses newer-than-floor syntax/APIs stays green here and only + * breaks inside a consumer pinned to the floor. This gate side-installs the + * EXACT floor release (pinned version + sha256, verified before extraction โ€” + * a mismatched download hard-fails) and dynamic-imports every override's + * node entry under that binary. + * The floor binary is a side install: it never touches PATH โ€” the harness + * keeps running on the repo's own Node and spawns the floor binary + * explicitly, so the two runtimes can't be confused. + * Usage: node scripts/npm/floor-node-smoke.mts + * Env: FLOOR_NODE_DIR overrides the install dir (default: RUNNER_TEMP, + * falling back to os.tmpdir()). + */ +import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath, pathToFileURL } from 'node:url' + +import { safeDeleteSync } from '@socketsecurity/lib-stable/fs/safe' +import { httpDownload } from '@socketsecurity/lib-stable/http-request/download' +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' +import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child' + +const logger = getDefaultLogger() + +// The floor is the low edge of packages/npm/*'s `engines.node: >=24` range. +// Bump BOTH the version and every sha256 together when the engines floor +// moves โ€” hashes come from https://nodejs.org/dist/v/SHASUMS256.txt. +// floor-node-pin-matches-engines (check --all) enforces the version half of +// that lock-step: it fails when any override's engines floor drifts from +// this pin. +export const FLOOR_NODE_VERSION = '24.0.0' +const FLOOR_NODE_PLATFORMS: Record = + { + 'darwin-arm64': { + asset: `node-v${FLOOR_NODE_VERSION}-darwin-arm64.tar.gz`, + sha256: + '194e2f3dd3ec8c2adcaa713ed40f44c5ca38467880e160974ceac1659be60121', + }, + 'darwin-x64': { + asset: `node-v${FLOOR_NODE_VERSION}-darwin-x64.tar.gz`, + sha256: + 'f716b3ce14a7e37a6cbf97c9de10d444d7da07ef833cd8da81dd944d111e6a4a', + }, + 'linux-x64': { + asset: `node-v${FLOOR_NODE_VERSION}-linux-x64.tar.xz`, + sha256: + '59b8af617dccd7f9f68cc8451b2aee1e86d6bd5cb92cd51dd6216a31b707efd7', + }, + 'win-x64': { + asset: `node-v${FLOOR_NODE_VERSION}-win-x64.zip`, + sha256: + '3d0fff80c87bb9a8d7f49f2f27832aa34a1477d137af46f5b14df5498be81304', + }, + } + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..', +) +const npmPackagesPath = path.join(repoRoot, 'packages/npm') + +/** + * Download + verify + extract the floor Node, returning its binary path. + * `httpDownload` streams to a temp sibling, checks the pinned sha256, and + * atomic-renames on success โ€” a tampered or truncated download never + * materializes at the archive path, let alone extracts. + */ +async function ensureFloorNode(): Promise { + const platKey = `${process.platform}-${process.arch}` + const pin = FLOOR_NODE_PLATFORMS[platKey] + if (!pin) { + throw new Error( + `no floor-node pin for ${platKey} โ€” add its asset + sha256 (from ` + + `https://nodejs.org/dist/v${FLOOR_NODE_VERSION}/SHASUMS256.txt) to FLOOR_NODE_PLATFORMS`, + ) + } + const baseDir = + process.env['FLOOR_NODE_DIR'] || process.env['RUNNER_TEMP'] || os.tmpdir() + // Strip the archive suffix (.tar.gz / .tar.xz / .zip) to get the top-level + // directory name the tarball extracts to (node-v--). + const extractDir = path.join( + baseDir, + pin.asset.replace(/\.(tar\.(gz|xz)|zip)$/, ''), + ) + const binPath = + process.platform === 'win32' + ? path.join(extractDir, 'node.exe') + : path.join(extractDir, 'bin/node') + if (existsSync(binPath)) { + return binPath + } + const url = `https://nodejs.org/dist/v${FLOOR_NODE_VERSION}/${pin.asset}` + logger.log(`downloading floor node ${FLOOR_NODE_VERSION} (${pin.asset})`) + mkdirSync(baseDir, { recursive: true }) + const archivePath = path.join(baseDir, pin.asset) + await httpDownload(url, archivePath, { sha256: pin.sha256 }) + // `tar -xf` auto-detects gz/xz; bsdtar (macOS + Windows runners) also + // extracts zip through the same flag. + const tar = spawnSync('tar', ['-xf', archivePath, '-C', baseDir], { + stdio: 'inherit', + }) + safeDeleteSync(archivePath, { force: true }) + if (tar.status !== 0 || !existsSync(binPath)) { + safeDeleteSync(extractDir, { force: true, recursive: true }) + throw new Error(`floor node extract failed (${pin.asset})`) + } + return binPath +} + +/** + * Resolve a package's node-runtime entry from its `exports` map (the + * overrides ship no `main`). Condition preference mirrors Node's own + * resolution for a `node` consumer; `types` never matches by construction. + */ +function resolveNodeEntry(exp: unknown): string | undefined { + if (typeof exp === 'string') { + return exp + } + if (!exp || typeof exp !== 'object') { + return undefined + } + const conditions = exp as Record + const conditionOrder = ['node', 'import', 'require', 'default'] + for (let i = 0, { length } = conditionOrder; i < length; i += 1) { + const key = conditionOrder[i]! + if (key in conditions) { + const resolved = resolveNodeEntry(conditions[key]) + if (resolved) { + return resolved + } + } + } + return undefined +} + +async function main(): Promise { + const floorNode = await ensureFloorNode() + const version = spawnSync(floorNode, ['--version'], { encoding: 'utf8' }) + logger.log( + `floor node ready: ${String(version.stdout).trim()} at ${floorNode}`, + ) + + const failures: string[] = [] + let imported = 0 + let skipped = 0 + const dirents = readdirSync(npmPackagesPath, { withFileTypes: true }) + for (let i = 0, { length } = dirents; i < length; i += 1) { + const dirent = dirents[i]! + if (!dirent.isDirectory()) { + continue + } + const pkgDir = path.join(npmPackagesPath, dirent.name) + const pkgJsonPath = path.join(pkgDir, 'package.json') + if (!existsSync(pkgJsonPath)) { + continue + } + const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) + const entryRel = pkgJson.exports + ? resolveNodeEntry(pkgJson.exports['.'] ?? pkgJson.exports) + : pkgJson.main + if (!entryRel) { + skipped += 1 + logger.log( + `skip ${dirent.name}: no node entry (bin-only or asset package)`, + ) + continue + } + const entryUrl = pathToFileURL(path.join(pkgDir, entryRel)).href + // Dynamic import loads every entry kind a floor-pinned ESM consumer can + // reach: .cjs, .js/ESM, and .json (which requires the import attribute + // on every Node โ€” `date` and `es-iterator-helpers` ship JSON entries). + const run = spawnSync( + floorNode, + [ + '--input-type=module', + '-e', + "const s = process.argv[1]; await (s.endsWith('.json') ? import(s, { with: { type: 'json' } }) : import(s))", + entryUrl, + ], + { encoding: 'utf8' }, + ) + if (run.status === 0) { + imported += 1 + } else { + failures.push( + `${dirent.name}: ${String(run.stderr).trim().split('\n')[0]}`, + ) + } + } + + logger.log( + `floor-node smoke: ${imported} imported, ${skipped} skipped, ${failures.length} failed ` + + `(node ${FLOOR_NODE_VERSION})`, + ) + if (failures.length > 0) { + for (let i = 0, { length } = failures; i < length; i += 1) { + logger.error(`FAIL ${failures[i]}`) + } + process.exitCode = 1 + } +} + +// Entrypoint-guarded: floor-node-pin-matches-engines (check --all) imports +// FLOOR_NODE_VERSION from this module, which must not trigger a download. +if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) { + main().catch((e: unknown) => { + logger.error(e) + process.exitCode = 1 + }) +} diff --git a/scripts/repo/check/floor-node-pin-matches-engines.mts b/scripts/repo/check/floor-node-pin-matches-engines.mts new file mode 100644 index 000000000..262e42bfc --- /dev/null +++ b/scripts/repo/check/floor-node-pin-matches-engines.mts @@ -0,0 +1,123 @@ +#!/usr/bin/env node +/** + * @file The floor-node smoke gate tests the Node version the overrides + * actually declare. The gate side-installs FLOOR_NODE_VERSION and imports + * every packages/npm/* entry under it โ€” its whole premise is that the pin + * IS the low edge of every override's `engines.node` range. That + * lock-step used to be a comment ("bump BOTH together"); this law + * enforces the version half: every override must declare `engines.node`, + * and each range's minimum satisfying version must equal the pin. An + * engines bump without a pin bump (or vice versa) would otherwise leave + * the gate green while proving the WRONG floor โ€” the exact silent drift + * it exists to prevent. (The sha256 half of the lock-step is enforced at + * runtime: httpDownload hard-fails on a hash mismatch.) + * Usage: node scripts/repo/check/floor-node-pin-matches-engines.mts. + */ + +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +// oxlint-disable-next-line socket/prefer-stable-external-semver -- @socketsecurity/lib-stable has no ./external/semver export at the pinned version; semver is a devDependency (scripts/tests only, not bundled). +import semver from 'semver' + +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' + +import { FLOOR_NODE_VERSION } from '../../npm/floor-node-smoke.mts' + +const logger = getDefaultLogger() + +export interface OverrideEngines { + name: string + enginesNode: string | undefined +} + +/** + * Classify each override's declared Node floor against the smoke gate's + * pin. Pure: directory reading happens in the caller so this stays + * unit-testable on synthetic fixtures. + */ +export function findFloorDrift( + overrides: OverrideEngines[], + floorVersion: string, +): string[] { + const findings: string[] = [] + for (let i = 0, { length } = overrides; i < length; i += 1) { + const { enginesNode, name } = overrides[i]! + if (!enginesNode) { + findings.push( + `${name}: no engines.node โ€” the floor gate's premise (every ` + + 'override declares its floor) does not hold for it', + ) + continue + } + let min: semver.SemVer | undefined + try { + // semver throws on a malformed range instead of returning null. + min = semver.minVersion(enginesNode, { loose: true }) ?? undefined + } catch { + min = undefined + } + if (!min) { + findings.push( + `${name}: engines.node ${JSON.stringify(enginesNode)} has no ` + + 'resolvable minimum version', + ) + continue + } + if (min.version !== floorVersion) { + findings.push( + `${name}: engines.node ${JSON.stringify(enginesNode)} floors at ` + + `${min.version}, but the smoke gate proves ${floorVersion}`, + ) + } + } + return findings +} + +function collectOverrideEngines(npmPackagesPath: string): OverrideEngines[] { + const out: OverrideEngines[] = [] + const dirents = readdirSync(npmPackagesPath, { withFileTypes: true }) + for (let i = 0, { length } = dirents; i < length; i += 1) { + const dirent = dirents[i]! + if (!dirent.isDirectory()) { + continue + } + const pkgJsonPath = path.join(npmPackagesPath, dirent.name, 'package.json') + if (!existsSync(pkgJsonPath)) { + continue + } + const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) as { + engines?: { node?: string | undefined } | undefined + } + out.push({ name: dirent.name, enginesNode: pkgJson.engines?.node }) + } + return out +} + +async function main(): Promise { + const repoRoot = path.resolve(import.meta.dirname, '../../..') + const overrides = collectOverrideEngines(path.join(repoRoot, 'packages/npm')) + const findings = findFloorDrift(overrides, FLOOR_NODE_VERSION) + if (findings.length) { + logger.fail( + [ + `${findings.length} override(s) out of lock-step with the floor-node pin (${FLOOR_NODE_VERSION}):`, + ...findings.map(f => ` ${f}`), + 'Fix: move engines.node and FLOOR_NODE_VERSION together โ€”', + ' scripts/npm/floor-node-smoke.mts (bump the pin + every sha256 from', + ' https://nodejs.org/dist/v/SHASUMS256.txt), or correct the', + ' drifted engines range.', + ].join('\n'), + ) + process.exitCode = 1 + return 1 + } + logger.success( + `all ${overrides.length} overrides floor at ${FLOOR_NODE_VERSION} โ€” the smoke gate proves the declared floor`, + ) + return 0 +} + +if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) { + void main() +} diff --git a/test/scripts/repo/check/floor-node-pin-matches-engines.test.mts b/test/scripts/repo/check/floor-node-pin-matches-engines.test.mts new file mode 100644 index 000000000..bd976f387 --- /dev/null +++ b/test/scripts/repo/check/floor-node-pin-matches-engines.test.mts @@ -0,0 +1,68 @@ +/** + * @file Unit tests for the floor-pin โ†” engines lock-step law's pure core: + * both arms of each classification (in-step, drifted range, missing or + * unresolvable engines). Directory scanning stays in the caller, so the + * suite runs on synthetic fixtures with no repo coupling. + */ + +import { describe, expect, it } from 'vitest' + +import { findFloorDrift } from '../../../../scripts/repo/check/floor-node-pin-matches-engines.mts' + +const FLOOR = '24.0.0' + +describe('scripts/repo/check/floor-node-pin-matches-engines', () => { + it('passes when every override floors exactly at the pin', () => { + expect( + findFloorDrift( + [ + { name: 'a', enginesNode: '>=24' }, + { name: 'b', enginesNode: '>=24.0.0' }, + { name: 'c', enginesNode: '^24.0.0 || >=25' }, + ], + FLOOR, + ), + ).toEqual([]) + }) + + it('flags a range whose floor drifted above or below the pin', () => { + const above = findFloorDrift([{ name: 'a', enginesNode: '>=26' }], FLOOR) + expect(above).toHaveLength(1) + expect(above[0]).toContain('floors at 26.0.0') + expect(above[0]).toContain('proves 24.0.0') + const below = findFloorDrift([{ name: 'b', enginesNode: '>=18.19' }], FLOOR) + expect(below).toHaveLength(1) + expect(below[0]).toContain('floors at 18.19.0') + }) + + it('flags a missing engines.node โ€” the gate premise breaks silently', () => { + const findings = findFloorDrift( + [{ name: 'a', enginesNode: undefined }], + FLOOR, + ) + expect(findings).toHaveLength(1) + expect(findings[0]).toContain('no engines.node') + }) + + it('flags an unresolvable range instead of crashing', () => { + const findings = findFloorDrift( + [{ name: 'a', enginesNode: 'not-a-range' }], + FLOOR, + ) + expect(findings).toHaveLength(1) + expect(findings[0]).toContain('no resolvable minimum') + }) + + it('reports every drifted override, not just the first', () => { + expect( + findFloorDrift( + [ + { name: 'a', enginesNode: '>=26' }, + { name: 'b', enginesNode: '>=24' }, + { name: 'c', enginesNode: undefined }, + ], + FLOOR, + ), + ).toHaveLength(2) + }) +})