diff --git a/.github/workflows/node.yaml b/.github/workflows/node.yaml index e46fb76..3c33455 100644 --- a/.github/workflows/node.yaml +++ b/.github/workflows/node.yaml @@ -15,10 +15,12 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false - - name: Use Node.js 22.x + - name: Use Node.js from .nvmrc uses: actions/setup-node@v6 with: - node-version: 22.x + node-version-file: .nvmrc + - name: Validate .nvmrc + run: node ./bin/ensureNvmrc.mjs - name: Prepare Environment run: | corepack enable @@ -45,10 +47,12 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false - - name: Use Node.js 22.x + - name: Use Node.js from .nvmrc uses: actions/setup-node@v6 with: - node-version: 22.x + node-version-file: .nvmrc + - name: Validate .nvmrc + run: node ./bin/ensureNvmrc.mjs - name: Prepare Environment run: | corepack enable diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 17cbf8e..9768a8f 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -32,12 +32,14 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - name: Use Node.js 18.x + - name: Use Node.js from .nvmrc uses: actions/setup-node@v6 with: - node-version: 18.x + node-version-file: .nvmrc - name: Enable corepack run: corepack enable + - name: Validate .nvmrc + run: node ./bin/ensureNvmrc.mjs - name: Determine publish info id: do-publish run: | @@ -114,10 +116,12 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - name: Use Node.js 24.x + - name: Use Node.js from .nvmrc uses: actions/setup-node@v6 with: - node-version: 24.x + node-version-file: .nvmrc + - name: Validate .nvmrc + run: node ./bin/ensureNvmrc.mjs - name: Download release artifact uses: actions/download-artifact@v8 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..1d9b783 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.12.0 diff --git a/bin/ensureNvmrc.mjs b/bin/ensureNvmrc.mjs new file mode 100644 index 0000000..700fd18 --- /dev/null +++ b/bin/ensureNvmrc.mjs @@ -0,0 +1,93 @@ +#! /usr/bin/env node +import { readFileSync } from 'fs' +import { readFile, writeFile } from 'fs/promises' + +const args = new Set(process.argv.slice(2)) +const shouldFix = args.has('--fix') || args.has('-f') + +function getRequiredNodeVersion() { + const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')) + const range = pkg?.engines?.node + if (!range || typeof range !== 'string') { + throw new Error('`package.json#engines.node` is missing or not a string') + } + + // Keep this script dependency-free: it runs in CI before `yarn install`. + // Prefer extracting the minimum required version (major.minor.patch) from common range shapes. + const match = + range.match(/>=\s*v?(?\d+)(?:\.(?\d+))?(?:\.(?\d+))?/) ?? + range.match(/\bv?(?\d+)(?:\.(?\d+))?(?:\.(?\d+))?\b/) + + const major = match?.groups?.major ? Number.parseInt(match.groups.major, 10) : NaN + const minor = match?.groups?.minor ? Number.parseInt(match.groups.minor, 10) : null + const patch = match?.groups?.patch ? Number.parseInt(match.groups.patch, 10) : null + + if (!Number.isInteger(major) || major <= 0) { + throw new Error(`Unable to determine required Node version from engines.node: "${range}"`) + } + if (minor !== null && (!Number.isInteger(minor) || minor < 0)) { + throw new Error(`Unable to determine required Node version from engines.node: "${range}"`) + } + if (patch !== null && (!Number.isInteger(patch) || patch < 0)) { + throw new Error(`Unable to determine required Node version from engines.node: "${range}"`) + } + + // If a minor is specified in engines, we enforce it in .nvmrc too. + // Patch defaults to 0 when omitted. + const expected = + minor === null ? String(major) : `${major}.${minor}.${patch === null ? 0 : patch}` + + return { expected, range } +} + +function normalizeNvmrc(value) { + const trimmed = String(value ?? '').trim() + if (trimmed.startsWith('v')) return trimmed.slice(1) + return trimmed +} + +async function main() { + const { expected, range } = getRequiredNodeVersion() + + let actual = null + try { + actual = normalizeNvmrc(await readFile(new URL('../.nvmrc', import.meta.url), 'utf-8')) + } catch (e) { + if (e?.code !== 'ENOENT') throw e + } + + if (actual === expected) return + + if (shouldFix) { + await writeFile(new URL('../.nvmrc', import.meta.url), expected + '\n', 'utf-8') + console.log(`Wrote .nvmrc (${expected}) from package.json engines.node (${range})`) + return + } + + if (actual === null) { + console.error( + [ + 'Missing .nvmrc.', + `Expected .nvmrc to contain: ${expected}`, + `Derived from package.json engines.node: ${range}`, + '', + 'Fix: yarn lint:nvmrc:fix', + ].join('\n'), + ) + } else { + console.error( + [ + '.nvmrc is out of sync with package.json.', + `Found .nvmrc: ${actual}`, + `Expected: ${expected}`, + `Derived from package.json engines.node: ${range}`, + '', + 'Fix: yarn lint:nvmrc:fix', + ].join('\n'), + ) + } + + process.exitCode = 1 +} + +await main() diff --git a/package.json b/package.json index 1d6d1be..351bddb 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,9 @@ "reset": "git clean -dfx && git reset --hard && yarn", "validate:dependencies": "yarn npm audit --environment production && run license-validate", "validate:dev-dependencies": "yarn npm audit --environment development", - "license-validate": "./bin/checkLicenses.mjs" + "license-validate": "./bin/checkLicenses.mjs", + "lint:nvmrc": "node ./bin/ensureNvmrc.mjs", + "lint:nvmrc:fix": "node ./bin/ensureNvmrc.mjs --fix" }, "files": [ "/CHANGELOG.md",