diff --git a/packages/build/src/plugins/node_version.ts b/packages/build/src/plugins/node_version.ts index 3a1b0224c4..0de069a201 100644 --- a/packages/build/src/plugins/node_version.ts +++ b/packages/build/src/plugins/node_version.ts @@ -1,9 +1,11 @@ +import { dirname } from 'path' import { execPath, version as currentVersion } from 'process' import semver from 'semver' import link from 'terminal-link' import { logWarning, logWarningSubHeader } from '../log/logger.js' +import { getPackageJson } from '../utils/package.js' export type PluginsLoadedFrom = 'auto_install' | 'local' | 'package.json' @@ -22,6 +24,7 @@ export type PluginsOptions = { * If the users preferred Node.js version is below that we have to fall back to the system node version */ const MINIMUM_REQUIRED_NODE_VERSION = '>=18.14.0' +const UPCOMING_MINIMUM_REQUIRED_NODE_VERSION = '>=22.12.0' /** * Local plugins and `package.json`-installed plugins use user's preferred Node.js version if higher than our minimum @@ -30,28 +33,39 @@ const MINIMUM_REQUIRED_NODE_VERSION = '>=18.14.0' * usually the system's Node.js version. * If the user Node version does not satisfy our supported engine range use our own system Node version */ -export const addPluginsNodeVersion = function ({ pluginsOptions, nodePath, userNodeVersion, logs }) { +export const addPluginsNodeVersion = function ({ + featureFlags, + pluginsOptions, + nodePath, + userNodeVersion, + logs, + systemLog, +}) { const currentNodeVersion = semver.clean(currentVersion) return Promise.all( pluginsOptions.map((pluginOptions) => addPluginNodeVersion({ + featureFlags, pluginOptions, currentNodeVersion, userNodeVersion, nodePath, logs, + systemLog, }), ), ) } const addPluginNodeVersion = async function ({ + featureFlags, pluginOptions, - pluginOptions: { loadedFrom, packageName }, + pluginOptions: { loadedFrom, packageName, pluginPath }, currentNodeVersion, userNodeVersion, nodePath, logs, + systemLog, }: { pluginOptions: PluginsOptions [key: string]: any @@ -66,6 +80,58 @@ const addPluginNodeVersion = async function ({ return systemNode } + if ( + featureFlags.build_warn_upcoming_system_version_change && + !semver.satisfies(userNodeVersion, UPCOMING_MINIMUM_REQUIRED_NODE_VERSION) + ) { + logWarningSubHeader( + logs, + `Warning: Starting June 16, 2026 plugin "${packageName}" will be executed with Node.js version 22.`, + ) + logWarning( + logs, + ` We're upgrading our system Node.js minimum on that day, which means the plugin cannot be executed with your defined Node.js version ${userNodeVersion}. + + Please make sure your plugin supports being run on Node.js 22. + + Read more about our minimum required version in our ${link( + 'forums announcement', + 'https://answers.netlify.com/t/build-plugins-end-of-support-for-node-js-18-node-js-20/162662', + )}`, + ) + + if (pluginPath) { + const pluginDir = dirname(pluginPath) + const { packageJson: pluginPackageJson, packageDir } = await getPackageJson(pluginDir) + + // `getPackageJson` walks up to the nearest `package.json`. For a `package.json`-installed + // plugin that's the plugin's own manifest, but for a local single-file plugin + // (e.g. `./plugins/foo.js`) it resolves an ancestor — typically the *site's* + // `package.json`, whose `engines.node` describes the site rather than the plugin. Only + // trust the resolved range when the manifest belongs to the plugin: an installed package, + // or a local plugin shipping its own `package.json` alongside its entry file. + const pluginOwnsPackageJson = loadedFrom === 'package.json' || packageDir === pluginDir + const pluginNodeVersionRange = pluginOwnsPackageJson ? pluginPackageJson.engines?.node : undefined + + // Ensure Node.js version is compatible with plugin's `engines.node` + if (!pluginOwnsPackageJson) { + systemLog(`plugin "${packageName}" node support range could not be determined (no own package.json)`) + } else if (!pluginNodeVersionRange) { + systemLog(`plugin "${packageName}" does not specify node support range`) + } else if (semver.satisfies('22.12.0', pluginNodeVersionRange)) { + systemLog(`plugin "${packageName}" node support range includes v22`) + } else { + logWarning( + logs, + ` In its package.json, the plugin "${packageName}" declares a Node.js version range ("${pluginNodeVersionRange}") that does not include Node.js 22. Please upgrade the plugin so it can be run on Node.js 22.`, + ) + systemLog(`plugin "${packageName}" node support range does NOT include v22`) + } + } else { + systemLog(`plugin "${packageName}" pluginPath not available`) + } + } + if (semver.satisfies(userNodeVersion, MINIMUM_REQUIRED_NODE_VERSION)) { return userNode } @@ -77,7 +143,7 @@ const addPluginNodeVersion = async function ({ Read more about our minimum required version in our ${link( 'forums announcement', - 'https://answers.netlify.com/t/build-plugins-end-of-support-for-node-js-14-node-js-16/136405', + 'https://answers.netlify.com/t/build-plugins-end-of-support-for-node-js-18-node-js-20/162662', )}`, ) diff --git a/packages/build/src/plugins/resolve.js b/packages/build/src/plugins/resolve.js index 08284ec5b9..23b3e7f6a9 100644 --- a/packages/build/src/plugins/resolve.js +++ b/packages/build/src/plugins/resolve.js @@ -40,10 +40,12 @@ export const resolvePluginsPath = async function ({ pluginsOptions.map((pluginOptions) => resolvePluginPath({ pluginOptions, buildDir, packagePath, autoPluginsDir })), ) const pluginsOptionsB = await addPluginsNodeVersion({ + featureFlags, pluginsOptions: pluginsOptionsA, nodePath, userNodeVersion, logs, + systemLog, }) const pluginsOptionsC = await addPinnedVersions({ pluginsOptions: pluginsOptionsB, api, siteInfo, sendStatus }) diff --git a/packages/build/tests/core/snapshots/tests.js.md b/packages/build/tests/core/snapshots/tests.js.md index 4efaa19f44..34b344a9cc 100644 --- a/packages/build/tests/core/snapshots/tests.js.md +++ b/packages/build/tests/core/snapshots/tests.js.md @@ -1173,7 +1173,7 @@ Generated by [AVA](https://avajs.dev). > Warning: ./plugin.js will be executed with Node.js version 1.0.0␊ The plugin cannot be executed with your defined Node.js version 1.0.0␊ ␊ - Read more about our minimum required version in our forums announcement (https://answers.netlify.com/t/build-plugins-end-of-support-for-node-js-14-node-js-16/136405)␊ + Read more about our minimum required version in our forums announcement (https://answers.netlify.com/t/build-plugins-end-of-support-for-node-js-18-node-js-20/162662)␊ ␊ > Loading plugins␊ - ./plugin.js@1.0.0 from netlify.toml␊ @@ -1232,7 +1232,7 @@ Generated by [AVA](https://avajs.dev). > Warning: netlify-plugin-test will be executed with Node.js version 1.0.0␊ The plugin cannot be executed with your defined Node.js version 1.0.0␊ ␊ - Read more about our minimum required version in our forums announcement (https://answers.netlify.com/t/build-plugins-end-of-support-for-node-js-14-node-js-16/136405)␊ + Read more about our minimum required version in our forums announcement (https://answers.netlify.com/t/build-plugins-end-of-support-for-node-js-18-node-js-20/162662)␊ ␊ > Loading plugins␊ - netlify-plugin-test@1.0.0 from netlify.toml and package.json␊ diff --git a/packages/build/tests/core/snapshots/tests.js.snap b/packages/build/tests/core/snapshots/tests.js.snap index 47bd11481a..edab53a293 100644 Binary files a/packages/build/tests/core/snapshots/tests.js.snap and b/packages/build/tests/core/snapshots/tests.js.snap differ diff --git a/packages/build/tests/plugins/fixtures/engines_no_package/netlify.toml b/packages/build/tests/plugins/fixtures/engines_no_package/netlify.toml new file mode 100644 index 0000000000..13115b066d --- /dev/null +++ b/packages/build/tests/plugins/fixtures/engines_no_package/netlify.toml @@ -0,0 +1,2 @@ +[[plugins]] +package = "./plugins/plugin.js" diff --git a/packages/build/tests/plugins/fixtures/engines_no_package/package.json b/packages/build/tests/plugins/fixtures/engines_no_package/package.json new file mode 100644 index 0000000000..8aa0c5ab3f --- /dev/null +++ b/packages/build/tests/plugins/fixtures/engines_no_package/package.json @@ -0,0 +1,11 @@ +{ + "name": "test", + "version": "0.0.1", + "type": "module", + "description": "test", + "license": "MIT", + "repository": "test", + "engines": { + "node": ">=99.0.0" + } +} diff --git a/packages/build/tests/plugins/fixtures/engines_no_package/plugins/manifest.yml b/packages/build/tests/plugins/fixtures/engines_no_package/plugins/manifest.yml new file mode 100644 index 0000000000..a3512f0259 --- /dev/null +++ b/packages/build/tests/plugins/fixtures/engines_no_package/plugins/manifest.yml @@ -0,0 +1,2 @@ +name: test +inputs: [] diff --git a/packages/build/tests/plugins/fixtures/engines_no_package/plugins/plugin.js b/packages/build/tests/plugins/fixtures/engines_no_package/plugins/plugin.js new file mode 100644 index 0000000000..0603db7376 --- /dev/null +++ b/packages/build/tests/plugins/fixtures/engines_no_package/plugins/plugin.js @@ -0,0 +1 @@ +export const onPreBuild = function () {} diff --git a/packages/build/tests/plugins/snapshots/tests.js.md b/packages/build/tests/plugins/snapshots/tests.js.md index 619c75abab..654cfe0097 100644 --- a/packages/build/tests/plugins/snapshots/tests.js.md +++ b/packages/build/tests/plugins/snapshots/tests.js.md @@ -870,7 +870,7 @@ Generated by [AVA](https://avajs.dev). > Warning: ./plugin.js will be executed with Node.js version 1.0.0␊ The plugin cannot be executed with your defined Node.js version 1.0.0␊ ␊ - Read more about our minimum required version in our forums announcement (https://answers.netlify.com/t/build-plugins-end-of-support-for-node-js-14-node-js-16/136405)␊ + Read more about our minimum required version in our forums announcement (https://answers.netlify.com/t/build-plugins-end-of-support-for-node-js-18-node-js-20/162662)␊ ␊ > Loading plugins␊ - ./plugin.js@1.0.0 from netlify.toml␊ diff --git a/packages/build/tests/plugins/snapshots/tests.js.snap b/packages/build/tests/plugins/snapshots/tests.js.snap index d950f076dc..fbb2693e44 100644 Binary files a/packages/build/tests/plugins/snapshots/tests.js.snap and b/packages/build/tests/plugins/snapshots/tests.js.snap differ diff --git a/packages/build/tests/plugins/tests.js b/packages/build/tests/plugins/tests.js index 2a2285f4bd..9520e8b567 100644 --- a/packages/build/tests/plugins/tests.js +++ b/packages/build/tests/plugins/tests.js @@ -111,14 +111,64 @@ test('Validate --node-path unsupported version does not fail when no plugins are }) test('Validate --node-path version is supported by the plugin', async (t) => { - const nodePath = getNodePath('16.14.0') - const output = await new Fixture('./fixtures/engines') - .withFlags({ - nodePath, - debug: false, - }) - .runWithBuild() - t.true(normalizeOutput(output).includes('The Node.js version is 1.0.0 but the plugin "./plugin.js" requires >=1.0.0')) + const systemLog = await tmp.file() + + try { + const nodePath = getNodePath('16.14.0') + const output = await new Fixture('./fixtures/engines') + .withFlags({ + nodePath, + featureFlags: { build_warn_upcoming_system_version_change: true }, + systemLogFile: systemLog.fd, + debug: false, + }) + .runWithBuild() + t.true( + normalizeOutput(output).includes('The Node.js version is 1.0.0 but the plugin "./plugin.js" requires >=1.0.0'), + ) + t.true( + output.includes('Warning: Starting June 16, 2026 plugin "./plugin.js" will be executed with Node.js version 22.'), + ) + t.true( + output.includes( + 'the plugin "./plugin.js" declares a Node.js version range (">=99.0.0") that does not include Node.js 22', + ), + ) + const systemLogContents = await fs.readFile(systemLog.path, 'utf8') + t.true(systemLogContents.includes('plugin "./plugin.js" node support range does NOT include v22')) + } finally { + await systemLog.cleanup() + } +}) + +test('Does not attribute the site package.json engines to a local single-file plugin', async (t) => { + const systemLog = await tmp.file() + + try { + const nodePath = getNodePath('16.14.0') + const output = await new Fixture('./fixtures/engines_no_package') + .withFlags({ + nodePath, + featureFlags: { build_warn_upcoming_system_version_change: true }, + systemLogFile: systemLog.fd, + debug: false, + }) + .runWithBuild() + // The general advance-notice warning still fires for any sub-v22 local plugin + t.true( + output.includes( + 'Warning: Starting June 16, 2026 plugin "./plugins/plugin.js" will be executed with Node.js version 22.', + ), + ) + // But the targeted "engines exclude v22" warning must NOT fire: the only package.json + // reachable by walking up is the site's, not the plugin's. + t.false(output.includes('declares a Node.js version range')) + const systemLogContents = await fs.readFile(systemLog.path, 'utf8') + t.true(systemLogContents.includes('node support range could not be determined (no own package.json)')) + t.false(systemLogContents.includes('node support range does NOT include v22')) + } finally { + await systemLog.cleanup() + } }) test('Validate --node-path exists', async (t) => {