@@ -3,13 +3,135 @@ 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+ let enginesNode = null;
41+ try {
42+ const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
43+ enginesNode = pkg?.engines?.node ?? null;
44+ } catch {}
45+
46+ const minVersion = parseEnginesVersion(enginesNode);
47+
48+ https.get('https://nodejs.org/dist/index.json', res => {
49+ let data = '';
50+ res.on('data', chunk => data += chunk);
51+ res.on('end', () => {
52+ const releases = JSON.parse(data);
53+
54+ // Filter LTS only, strip 'v' prefix
55+ const lts = releases
56+ .filter(r => r.lts)
57+ .map(r => r.version.replace(/^v/, ''));
58+
59+ // Group by major, pick highest semver per major
60+ const byMajor = {};
61+ for (const v of lts) {
62+ const major = parseInt(v.split('.')[0], 10);
63+ if (!byMajor[major]) byMajor[major] = [];
64+ byMajor[major].push(v);
65+ }
66+
67+ const latestPerMajor = Object.entries(byMajor)
68+ .map(([major, versions]) => {
69+ versions.sort(compareSemver);
70+ return { major: parseInt(major, 10), version: versions[0] };
71+ });
72+
73+ // Sort majors descending
74+ latestPerMajor.sort((a, b) => b.major - a.major);
75+
76+ // Take latest 4 LTS majors
77+ const top4 = latestPerMajor.slice(0, 4);
78+ const includedVersions = new Set(top4.map(e => e.version));
79+ const extra = [];
80+
81+ if (minVersion !== null) {
82+ // Resolve the exact minimum version string to test:
83+ // - patch specified (>= 18.18.2): use exactly 18.18.2
84+ // - minor specified, no patch (>= 18.18): use latest 18.18.x
85+ // - only major (>= 18): use latest 18.x.x (same as latestPerMajor entry)
86+ let minExact = null;
787
88+ if (minVersion.patchSpecified) {
89+ // Use the exact version if it exists in LTS releases
90+ const candidate = `${minVersion.major}.${minVersion.minor}.${minVersion.patch}`;
91+ if (lts.includes(candidate)) minExact = candidate;
92+ } else if (minVersion.minorSpecified) {
93+ // Find the latest patch for this major.minor
94+ const candidates = lts.filter(v => {
95+ const [maj, min] = v.split('.').map(Number);
96+ return maj === minVersion.major && min === minVersion.minor;
97+ });
98+ candidates.sort(compareSemver);
99+ if (candidates.length > 0) minExact = candidates[0]; // highest patch
100+ }
101+ // If only major specified, minExact stays null — latestPerMajor already covers it
102+
103+ // Add the resolved minimum version if not already included
104+ if (minExact && !includedVersions.has(minExact)) {
105+ extra.push({ major: minVersion.major, version: minExact });
106+ includedVersions.add(minExact);
107+ }
108+
109+ // Always also add the latest patch of the minimum major if not already included
110+ const latestOfMinMajor = latestPerMajor.find(e => e.major === minVersion.major);
111+ if (latestOfMinMajor && !includedVersions.has(latestOfMinMajor.version)) {
112+ extra.push(latestOfMinMajor);
113+ includedVersions.add(latestOfMinMajor.version);
114+ }
115+ }
116+
117+ // Merge and sort ascending (oldest -> newest)
118+ const all = [...top4, ...extra];
119+ all.sort((a, b) => compareSemver(b.version, a.version));
120+
121+ const matrix = { "node-version": all.map(e => e.version) };
122+ console.log(`matrix=${JSON.stringify(matrix)}`);
123+ });
124+ });
125+ EOF
126+
127+ test :
128+ needs : resolve-node-versions
8129 runs-on : ubuntu-latest
9130
10131 strategy :
11- matrix :
12- node-version : [18.x, 20.x, 22.x, 24.x]
132+ fail-fast : false
133+ matrix : ${{ fromJson(needs.resolve-node-versions.outputs.matrix) }}
134+ name : Node ${{ matrix.node-version }}
13135
14136 steps :
15137 - uses : actions/checkout@v4
@@ -18,13 +140,36 @@ jobs:
18140 uses : actions/setup-node@v4
19141 with :
20142 node-version : ${{ matrix.node-version }}
21- cache : ' yarn'
22143
23- - name : Install dependencies
24- run : yarn --frozen-lockfile --non-interactive --prefer-offline
144+ - name : Enable Corepack
145+ run : corepack enable
25146
26- - name : Test
27- run : yarn test
147+ - name : Detect Yarn version
148+ id : yarn-version
149+ run : |
150+ YARN_VERSION=$(node -p "
151+ try {
152+ const pm = require('./package.json').packageManager ?? '';
153+ const match = pm.match(/^yarn@(\d+)/);
154+ match ? match[1] : '';
155+ } catch { '' }
156+ ")
157+ if [ -z "$YARN_VERSION" ]; then
158+ YARN_VERSION=$(yarn --version | cut -d. -f1)
159+ fi
160+ echo "major=$YARN_VERSION" >> "$GITHUB_OUTPUT"
161+ echo "Detected Yarn major version: $YARN_VERSION"
162+
163+ - name : Install dependencies
164+ run : |
165+ if [ "${{ steps.yarn-version.outputs.major }}" = "1" ]; then
166+ yarn install --frozen-lockfile --non-interactive --prefer-offline
167+ else
168+ yarn install --immutable
169+ fi
28170
29171 - name : Lint
30172 run : yarn lint
173+
174+ - name : Test
175+ run : yarn test
0 commit comments