@@ -3,13 +3,136 @@ name: Tests
33on : [push, pull_request]
44
55jobs :
6- build :
6+ resolve-node-versions :
7+ runs-on : ubuntu-latest
8+ outputs :
9+ matrix : ${{ steps.set-matrix.outputs.matrix }}
10+ steps :
11+ - id : set-matrix
12+ run : |
13+ node <<'EOF' >> $GITHUB_OUTPUT
14+ const https = require('https');
15+ const fs = require('fs');
16+
17+ function compareSemver(a, b) {
18+ const pa = a.split('.').map(Number);
19+ const pb = b.split('.').map(Number);
20+ for (let i = 0; i < 3; i++) {
21+ if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pb[i] ?? 0) - (pa[i] ?? 0);
22+ }
23+ return 0;
24+ }
25+
26+ // Returns { major, minor, patch, minorSpecified, patchSpecified } or null
27+ function parseEnginesVersion(enginesNode) {
28+ if (!enginesNode) return null;
29+ const match = enginesNode.match(/>=\s*(\d+)(?:\.(\d+)(?:\.(\d+))?)?/);
30+ if (!match) return null;
31+ return {
32+ major: parseInt(match[1], 10),
33+ minor: match[2] !== undefined ? parseInt(match[2], 10) : null,
34+ patch: match[3] !== undefined ? parseInt(match[3], 10) : null,
35+ minorSpecified: match[2] !== undefined,
36+ patchSpecified: match[3] !== undefined,
37+ };
38+ }
39+
40+ // Build the version spec to pass to setup-node for the minimum version.
41+ // We let setup-node resolve the latest patch when patch is omitted.
42+ // e.g. ">= 18" -> "18"
43+ // ">= 18.18" -> "18.18"
44+ // ">= 18.18.2" -> "18.18.2"
45+ function minVersionSpec(minVersion) {
46+ if (minVersion.patchSpecified) return `${minVersion.major}.${minVersion.minor}.${minVersion.patch}`;
47+ if (minVersion.minorSpecified) return `${minVersion.major}.${minVersion.minor}`;
48+ return `${minVersion.major}`;
49+ }
50+
51+ let enginesNode = null;
52+ try {
53+ const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
54+ enginesNode = pkg?.engines?.node ?? null;
55+ } catch {}
56+
57+ const minVersion = parseEnginesVersion(enginesNode);
58+
59+ https.get('https://nodejs.org/dist/index.json', res => {
60+ let data = '';
61+ res.on('data', chunk => data += chunk);
62+ res.on('end', () => {
63+ const releases = JSON.parse(data);
64+
65+ const lts = releases
66+ .filter(r => r.lts)
67+ .map(r => r.version.replace(/^v/, ''));
68+
69+ // Group by major, pick highest semver per major
70+ const byMajor = {};
71+ for (const v of lts) {
72+ const major = parseInt(v.split('.')[0], 10);
73+ if (!byMajor[major]) byMajor[major] = [];
74+ byMajor[major].push(v);
75+ }
76+
77+ const latestPerMajor = Object.entries(byMajor)
78+ .map(([major, versions]) => {
79+ versions.sort(compareSemver);
80+ return { major: parseInt(major, 10), version: versions[0] };
81+ });
82+
83+ latestPerMajor.sort((a, b) => b.major - a.major);
84+
85+ // Take latest 4 LTS majors (these are exact resolved versions)
86+ const top4 = latestPerMajor.slice(0, 4);
87+ const top4Majors = new Set(top4.map(e => e.major));
88+
89+ const extra = [];
790
91+ if (minVersion !== null) {
92+ const spec = minVersionSpec(minVersion);
93+
94+ // Check if this spec is already fully covered:
95+ // - If only major specified and that major is in top4, it's covered.
96+ // - If minor or patch specified, we always add it — even if the major
97+ // is in top4, because the top4 entry is the *latest* of that major,
98+ // which may differ from the minimum spec.
99+ const alreadyCovered =
100+ !minVersion.minorSpecified && top4Majors.has(minVersion.major);
101+
102+ if (!alreadyCovered) {
103+ // Check for exact duplicate against resolved top4 versions
104+ const alreadyExact = top4.some(e => e.version === spec);
105+ if (!alreadyExact) {
106+ extra.push({ major: minVersion.major, version: spec });
107+ }
108+ }
109+
110+ // Also ensure the latest of the min major is in the list,
111+ // in case it wasn't in top4 (e.g. engines requires an older major)
112+ const latestOfMinMajor = latestPerMajor.find(e => e.major === minVersion.major);
113+ if (latestOfMinMajor && !top4Majors.has(minVersion.major)) {
114+ extra.push(latestOfMinMajor);
115+ }
116+ }
117+
118+ // Merge, sort ascending by semver (oldest -> newest)
119+ const all = [...top4, ...extra];
120+ all.sort((a, b) => compareSemver(b.version, a.version));
121+
122+ const matrix = { "node-version": all.map(e => e.version) };
123+ console.log(`matrix=${JSON.stringify(matrix)}`);
124+ });
125+ });
126+ EOF
127+
128+ test :
129+ needs : resolve-node-versions
8130 runs-on : ubuntu-latest
9131
10132 strategy :
11- matrix :
12- node-version : [18.x, 20.x, 22.x, 24.x]
133+ fail-fast : false
134+ matrix : ${{ fromJson(needs.resolve-node-versions.outputs.matrix) }}
135+ name : Node ${{ matrix.node-version }}
13136
14137 steps :
15138 - uses : actions/checkout@v4
@@ -18,13 +141,36 @@ jobs:
18141 uses : actions/setup-node@v4
19142 with :
20143 node-version : ${{ matrix.node-version }}
21- cache : ' yarn'
22144
23- - name : Install dependencies
24- run : yarn --frozen-lockfile --non-interactive --prefer-offline
145+ - name : Enable Corepack
146+ run : corepack enable
25147
26- - name : Test
27- run : yarn test
148+ - name : Detect Yarn version
149+ id : yarn-version
150+ run : |
151+ YARN_VERSION=$(node -p "
152+ try {
153+ const pm = require('./package.json').packageManager ?? '';
154+ const match = pm.match(/^yarn@(\d+)/);
155+ match ? match[1] : '';
156+ } catch { '' }
157+ ")
158+ if [ -z "$YARN_VERSION" ]; then
159+ YARN_VERSION=$(yarn --version | cut -d. -f1)
160+ fi
161+ echo "major=$YARN_VERSION" >> "$GITHUB_OUTPUT"
162+ echo "Detected Yarn major version: $YARN_VERSION"
163+
164+ - name : Install dependencies
165+ run : |
166+ if [ "${{ steps.yarn-version.outputs.major }}" = "1" ]; then
167+ yarn install --frozen-lockfile --non-interactive --prefer-offline
168+ else
169+ yarn install --immutable
170+ fi
28171
29172 - name : Lint
30173 run : yarn lint
174+
175+ - name : Test
176+ run : yarn test
0 commit comments