|
| 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