[E00-S01-T08] Node engine restricted to 24.x #375

Merged
kpcto merged 3 commits from feature/161 into main 2026-08-28 09:18:24 +00:00
4 changed files with 147 additions and 4 deletions
+6 -4
View File
@@ -27,9 +27,11 @@ accidental dependency graph: no `packages/*` package may import a concrete
## Prerequisites ## Prerequisites
- **Git** — to clone the repository. - **Git** — to clone the repository.
- **Node.js 24.x** — the CI pipeline runs Node 24 and this is the supported - **Node.js 24.x** — required. The root `package.json` restricts the Node
line for the workspace (a hard `engines` restriction is tracked separately as engine to 24.x (`engines.node`), and the committed `pnpm-workspace.yaml` sets
E00-S01-T08). Any Node ≥ 22 is enough for the current bootstrap commands. `engineStrict: true`, so `pnpm install` on any other Node version is
**rejected** (`ERR_PNPM_UNSUPPORTED_ENGINE`) instead of merely warned.
Install Node 24.x via `nvm`, `fnm` or another version manager to match CI.
- **pnpm 11.23.0** — pinned via the `packageManager` field in the root - **pnpm 11.23.0** — pinned via the `packageManager` field in the root
`package.json`. The easiest way to get exactly this version is Corepack, `package.json`. The easiest way to get exactly this version is Corepack,
which ships with Node.js (`corepack enable`). which ships with Node.js (`corepack enable`).
@@ -128,7 +130,7 @@ pnpm --filter @personal-blog/server start # loads compiled server entrypoint,
| --- | --- | | --- | --- |
| `pnpm: command not found` | Corepack shims not activated — run `corepack enable`, or prefix commands with `corepack pnpm ...`. | | `pnpm: command not found` | Corepack shims not activated — run `corepack enable`, or prefix commands with `corepack pnpm ...`. |
| `ERR_PNPM_OUTDATED_LOCKFILE` | `pnpm-lock.yaml` is out of date with the manifests. Run `pnpm install` (unfrozen) and commit the lockfile update. | | `ERR_PNPM_OUTDATED_LOCKFILE` | `pnpm-lock.yaml` is out of date with the manifests. Run `pnpm install` (unfrozen) and commit the lockfile update. |
| Node version warnings / unexpected behavior | Use Node 24.x to match CI (e.g. via `nvm`, `fnm` or another version manager). A hard `engines` restriction is tracked as E00-S01-T08. | | `ERR_PNPM_UNSUPPORTED_ENGINE` on install | Your Node version is outside the supported 24.x engine line (`engines.node` in the root `package.json`, enforced by `engineStrict: true` in `pnpm-workspace.yaml`). Install Node 24.x (e.g. via `nvm`, `fnm` or another version manager). |
| `start` exits immediately with no output | Expected at bootstrap — the server entrypoint is a placeholder module; the Fastify 5 shell is a later story (see [Run](#run)). | | `start` exits immediately with no output | Expected at bootstrap — the server entrypoint is a placeholder module; the Fastify 5 shell is a later story (see [Run](#run)). |
| `.env` files | `.env`/`.env.*` are git-ignored; a committed `.env.example` template lands with the environment story (E00-S04). | | `.env` files | `.env`/`.env.*` are git-ignored; a committed `.env.example` template lands with the environment story (E00-S04). |
+3
View File
@@ -4,6 +4,9 @@
"private": true, "private": true,
"type": "module", "type": "module",
"description": "EPPP — a modular personal blogging platform. Workspace bootstrap (E00-S01).", "description": "EPPP — a modular personal blogging platform. Workspace bootstrap (E00-S01).",
"engines": {
"node": ">=24.0.0 <25.0.0"
},
"scripts": { "scripts": {
"build": "pnpm -r run build", "build": "pnpm -r run build",
"test": "node --test \"tests/**/*.test.mjs\"", "test": "node --test \"tests/**/*.test.mjs\"",
+5
View File
@@ -2,3 +2,8 @@ packages:
- 'apps/*' - 'apps/*'
- 'packages/*' - 'packages/*'
- 'extensions/*' - 'extensions/*'
# E00-S01-T08 — reject installs on unsupported Node versions instead of
# warning: root package.json restricts engines.node to 24.x, and engineStrict
# turns a mismatch into a hard install error (ERR_PNPM_UNSUPPORTED_ENGINE).
engineStrict: true
+133
View File
@@ -0,0 +1,133 @@
/**
* Node engine test — locks in the [E00-S01-T08] Node engine restriction to
* 24.x for the workspace.
*
* Acceptance criteria covered (each test fails without the committed config):
* - "Node engine is restricted to 24.x in the package manifest" → root
* `package.json` declares `engines.node` exactly `>=24.0.0 <25.0.0`, and a
* minimal semver range check proves the range admits 24.x versions
* (24.0.0 … 24.99.99) while excluding every other major line.
* - "an unsupported Node version is rejected" → the same range check proves
* 18.x/22.x/23.x/25.x/26.x are outside the range, the committed
* `pnpm-workspace.yaml` sets `engineStrict: true` so pnpm hard-fails the
* install on those versions (ERR_PNPM_UNSUPPORTED_ENGINE) instead of
* warning, and the current runtime is asserted to satisfy the range (so
* `pnpm test` on an unsupported Node fails loudly).
*
* Run: `node --test tests/node-engine.test.mjs`
* (node:test — built into Node >= 18; no dependencies, lockfile untouched.)
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8');
/** The exact engines.node value committed in the root package.json (24.x). */
const NODE_ENGINE_RANGE = '>=24.0.0 <25.0.0';
/** Versions inside the 24.x line that must satisfy the range. */
const SUPPORTED_EXAMPLES = ['24.0.0', '24.1.0', '24.20.0', '24.99.99'];
/** Versions outside the 24.x line that must be rejected by the range. */
const UNSUPPORTED_EXAMPLES = ['18.0.0', '20.0.0', '22.0.0', '22.23.2', '23.0.0', '25.0.0', '26.0.0'];
// ---------------------------------------------------------------------------
// Minimal semver range checker (no dependencies, lockfile untouched)
// ---------------------------------------------------------------------------
/** Parses "v?MAJOR.MINOR.PATCH" (pre-release/build suffixes ignored). */
function parseVersion(version) {
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(String(version).trim());
assert.ok(match, `"${version}" is not a MAJOR.MINOR.PATCH semver version`);
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
/** Compares two [major, minor, patch] tuples: -1, 0 or 1. */
function compareVersions(a, b) {
for (let i = 0; i < 3; i += 1) {
if (a[i] < b[i]) return -1;
if (a[i] > b[i]) return 1;
}
return 0;
}
/**
* Checks a version against a whitespace-separated comparator range such as
* ">=24.0.0 <25.0.0" (AND semantics). Throws on any comparator form this
* workspace does not use, so an intentional range change must update this
* checker in the same change.
*/
function satisfies(version, range) {
const comparators = range.trim().split(/\s+/);
assert.ok(comparators.length > 0, `engines range must not be empty: "${range}"`);
const v = parseVersion(version);
return comparators.every((token) => {
const match = /^(>=|<=|>|<|=)?v?(\d+)\.(\d+)\.(\d+)$/.exec(token);
assert.ok(match, `unsupported comparator in engines range "${range}": "${token}"`);
const [, operator = '=', major, minor, patch] = match;
const c = compareVersions(v, [Number(major), Number(minor), Number(patch)]);
switch (operator) {
case '>=': return c >= 0;
case '<=': return c <= 0;
case '>': return c > 0;
case '<': return c < 0;
default: return c === 0; // '=' (exact)
}
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test('root package.json declares engines.node restricted to 24.x', () => {
const manifest = JSON.parse(read('package.json'));
assert.equal(
manifest.engines?.node,
NODE_ENGINE_RANGE,
'root package.json must declare engines.node exactly ">=24.0.0 <25.0.0" (24.x)',
);
for (const version of SUPPORTED_EXAMPLES) {
assert.ok(satisfies(version, NODE_ENGINE_RANGE), `${version} must satisfy ${NODE_ENGINE_RANGE}`);
}
});
test('the declared engines range rejects unsupported Node versions', () => {
for (const version of UNSUPPORTED_EXAMPLES) {
assert.equal(
satisfies(version, NODE_ENGINE_RANGE),
false,
`${version} must be outside the 24.x range (${NODE_ENGINE_RANGE})`,
);
}
});
test('the current Node runtime satisfies the declared engines range', () => {
assert.ok(
satisfies(process.versions.node, NODE_ENGINE_RANGE),
`runtime Node ${process.versions.node} is not within the supported 24.x line — ` +
'install a Node 24.x release (install is rejected via engine-strict otherwise)',
);
});
test('the committed pnpm-workspace.yaml enforces engines so an unsupported Node is rejected', () => {
const workspace = read('pnpm-workspace.yaml');
assert.ok(
workspace.includes('engineStrict: true'),
'pnpm-workspace.yaml must set engineStrict: true so pnpm rejects an unsupported Node version at install',
);
});
test('committed CI workflow installs on Node 24', () => {
const workflow = read('.gitea/workflows/ci.yml');
assert.ok(
workflow.includes("node-version: '24'"),
'CI must run on Node 24 (the supported engines line)',
);
});