Skip to content

Commit 21e7bb5

Browse files
Kikobeatsclaude
andcommitted
ci: check the package on its minimum Node.js version
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent cb471db commit 21e7bb5

1 file changed

Lines changed: 215 additions & 0 deletions

File tree

‎.github/workflows/engines.yml‎

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
name: engines
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- master
8+
9+
permissions:
10+
contents: read
11+
12+
env:
13+
pnpm_config_dangerously_allow_all_builds: true
14+
SKIP_INSTALL_SIMPLE_GIT_HOOKS: true
15+
16+
jobs:
17+
pack:
18+
name: Pack
19+
runs-on: ubuntu-latest
20+
outputs:
21+
matrix: ${{ steps.pack.outputs.matrix }}
22+
steps:
23+
- name: Checkout
24+
uses: actions/checkout@v7
25+
with:
26+
persist-credentials: false
27+
- name: Setup Node.js
28+
uses: actions/setup-node@v7
29+
with:
30+
node-version: lts/*
31+
- name: Setup PNPM
32+
uses: pnpm/action-setup@v6
33+
with:
34+
version: latest
35+
- name: Install
36+
run: pnpm install
37+
- name: Pack
38+
id: pack
39+
run: |
40+
node --input-type=module - <<'EOF'
41+
import { execFileSync } from 'node:child_process'
42+
import { appendFileSync, mkdirSync, readdirSync, readFileSync } from 'node:fs'
43+
import path from 'node:path'
44+
45+
const OUT = path.resolve('tarballs')
46+
const FLOOR = /^>=\s*(\d+(?:\.\d+){0,2})$/
47+
const WORKSPACE_DEPENDENCY_FIELDS = ['dependencies', 'optionalDependencies']
48+
const WORKSPACE_ALIAS = /^workspace:((?:@[^/@]+\/)?[^@]+)@/
49+
50+
const pnpmOutput = (args, cwd) =>
51+
execFileSync('pnpm', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] })
52+
const pnpm = (args, cwd) => execFileSync('pnpm', args, { cwd, stdio: ['ignore', 'inherit', 'inherit'] })
53+
54+
const floorOf = manifest => {
55+
const range = manifest.engines?.node
56+
const match = range?.trim().match(FLOOR)
57+
if (!match) throw new Error(`${manifest.name}: engines.node ${JSON.stringify(range)} is not a ">= x" floor`)
58+
return match[1]
59+
}
60+
61+
const packages = JSON.parse(pnpmOutput(['ls', '--recursive', '--depth', '-1', '--json']))
62+
.filter(({ private: isPrivate }) => !isPrivate)
63+
.map(({ path: dir }) => {
64+
const manifest = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'))
65+
return { dir, manifest, floor: floorOf(manifest) }
66+
})
67+
68+
for (const { dir, manifest } of packages) {
69+
if (manifest.scripts?.prepublishOnly) pnpm(['run', 'prepublishOnly'], dir)
70+
}
71+
72+
mkdirSync(OUT, { recursive: true })
73+
const tarballOf = new Map()
74+
for (const { dir, manifest } of packages) {
75+
const before = new Set(readdirSync(OUT))
76+
pnpm(['pack', '--pack-destination', OUT], dir)
77+
const [tarball] = readdirSync(OUT).filter(file => !before.has(file))
78+
tarballOf.set(manifest.name, tarball)
79+
}
80+
81+
const workspaceDependencies = manifest =>
82+
WORKSPACE_DEPENDENCY_FIELDS.flatMap(field =>
83+
Object.entries(manifest[field] ?? {})
84+
.filter(([, spec]) => spec.startsWith('workspace:'))
85+
.map(([name, spec]) => spec.match(WORKSPACE_ALIAS)?.[1] ?? name)
86+
)
87+
88+
const manifestOf = new Map(packages.map(({ manifest }) => [manifest.name, manifest]))
89+
const closure = name => {
90+
const seen = new Set()
91+
const visit = current => {
92+
if (seen.has(current)) return
93+
const manifest = manifestOf.get(current)
94+
if (!manifest) throw new Error(`${name} depends on ${current}, a private workspace package that is never published`)
95+
seen.add(current)
96+
workspaceDependencies(manifest).forEach(visit)
97+
}
98+
visit(name)
99+
return [...seen]
100+
}
101+
102+
const include = packages.map(({ manifest, floor }) => ({
103+
name: manifest.name,
104+
floor,
105+
tarballs: closure(manifest.name).map(name => tarballOf.get(name)).join(' ')
106+
}))
107+
108+
console.table(include)
109+
appendFileSync(process.env.GITHUB_OUTPUT, `matrix=${JSON.stringify({ include })}\n`)
110+
EOF
111+
- name: Upload
112+
uses: actions/upload-artifact@v7
113+
with:
114+
name: tarballs
115+
path: tarballs
116+
117+
floor:
118+
name: Node ${{ matrix.floor }} ${{ matrix.name }}
119+
needs: pack
120+
runs-on: ubuntu-latest
121+
timeout-minutes: 10
122+
strategy:
123+
fail-fast: false
124+
matrix: ${{ fromJson(needs.pack.outputs.matrix) }}
125+
steps:
126+
- name: Setup Node.js
127+
uses: actions/setup-node@v7
128+
with:
129+
node-version: ${{ matrix.floor }}
130+
- name: Download
131+
uses: actions/download-artifact@v8
132+
with:
133+
name: tarballs
134+
path: tarballs
135+
- name: Install with engine-strict
136+
env:
137+
TARBALLS: ${{ matrix.tarballs }}
138+
run: |
139+
mkdir consumer
140+
cd consumer
141+
echo '{"private":true}' > package.json
142+
read -ra tarballs <<< "$TARBALLS"
143+
npm install --engine-strict --no-audit --no-fund "${tarballs[@]/#/../tarballs/}"
144+
- name: Load every entry point
145+
working-directory: consumer
146+
env:
147+
PACKAGE: ${{ matrix.name }}
148+
run: |
149+
echo 'module.exports = specifier => import(specifier)' > import.js
150+
cat > smoke.js <<'EOF'
151+
'use strict'
152+
153+
const fs = require('fs')
154+
const path = require('path')
155+
156+
const name = process.argv[2]
157+
const manifest = JSON.parse(fs.readFileSync(path.join('node_modules', name, 'package.json'), 'utf8'))
158+
const TYPE_DECLARATION = /\.d\.[cm]?ts$/
159+
const IMPORT_ONLY = ['ERR_REQUIRE_ESM', 'ERR_REQUIRE_ASYNC_MODULE', 'ERR_PACKAGE_PATH_NOT_EXPORTED']
160+
161+
const targets = (value, condition) => {
162+
if (typeof value === 'string') return condition === 'types' ? [] : [value]
163+
if (!value || typeof value !== 'object') return []
164+
return Object.keys(value).reduce((all, key) => all.concat(targets(value[key], key)), [])
165+
}
166+
167+
const isSubpathMap = value =>
168+
value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).some(key => key[0] === '.')
169+
170+
const entryPoints = exportsField => {
171+
if (exportsField == null) return ['.']
172+
const subpaths = isSubpathMap(exportsField) ? exportsField : { '.': exportsField }
173+
return Object.keys(subpaths).filter(
174+
subpath =>
175+
subpath.indexOf('*') === -1 && targets(subpaths[subpath]).some(target => !TYPE_DECLARATION.test(target))
176+
)
177+
}
178+
179+
const load = specifier => {
180+
try {
181+
return Promise.resolve({ value: require(specifier) })
182+
} catch (error) {
183+
if (IMPORT_ONLY.indexOf(error.code) === -1) return Promise.reject(error)
184+
return require('./import.js')(specifier).then(value => ({ value }))
185+
}
186+
}
187+
188+
process.exitCode = 1
189+
190+
const specifiers = entryPoints(manifest.exports).map(subpath => name + subpath.slice(1))
191+
192+
specifiers
193+
.reduce(
194+
(previous, specifier) =>
195+
previous.then(failures =>
196+
load(specifier)
197+
.then(loaded => {
198+
if (loaded.value === undefined) throw new Error('exports undefined')
199+
})
200+
.then(
201+
() => {
202+
console.log('ok ' + specifier)
203+
return failures
204+
},
205+
error => {
206+
console.log('FAIL ' + specifier + '\n' + (error && error.stack))
207+
return failures + 1
208+
}
209+
)
210+
),
211+
Promise.resolve(0)
212+
)
213+
.then(failures => process.exit(failures ? 1 : 0))
214+
EOF
215+
node smoke.js "$PACKAGE"

0 commit comments

Comments
 (0)