|
1 | 1 | 'use strict' |
2 | 2 |
|
3 | | -const { subset } = require('semver') |
| 3 | +const { clean, coerce, intersects, subset } = require('semver') |
| 4 | + |
4 | 5 | const latests = require('./package.json').dependencies |
5 | 6 |
|
6 | 7 | const exactVersionExp = /^=?\d+\.\d+\.\d+/ |
7 | 8 |
|
| 9 | +// Packages whose published majors are not contiguous. `getVersionList()` must not auto-fill their in-between majors: |
| 10 | +// the missing majors were never published, so installing them fails. A failing install of a multi-major range is the |
| 11 | +// signal to add a package here (the install script's error points back to this list). New entries must explain the gap |
| 12 | +// so the next reader does not "fix" it by removing the entry. |
| 13 | +const nonConsecutiveMajorPackages = new Set([ |
| 14 | + 'graphql', // jumps from 0.x straight to 14.x (no 1.x–13.x); 14.x–17.x are covered via the apollo externals entries |
| 15 | + '@redis/client', // jumps from 2.x to 5.x (no 3.x–4.x) |
| 16 | +]) |
| 17 | + |
8 | 18 | /** |
9 | 19 | * @param {string} name |
10 | 20 | * @param {string} range |
@@ -40,6 +50,150 @@ function capSubrange (name, subrange) { |
40 | 50 | return `${subrange} <=${latests[name]}` |
41 | 51 | } |
42 | 52 |
|
| 53 | +/** |
| 54 | + * Expand a module's declared version entries into the de-duplicated set of version keys to install and test. Each key |
| 55 | + * maps to a `versions/<name>@<key>` workspace folder; the install script and `withVersions()` share this so the set of |
| 56 | + * installed folders and the set of tested folders never drift apart. |
| 57 | + * |
| 58 | + * Per declared range this yields the lowest supported version (pinned exactly) and the newest version of every major |
| 59 | + * the range spans, keyed by the bare major so the key resolves to that major's latest. Covering each major explicitly |
| 60 | + * (rather than emitting the raw range, which resolves only to the newest version of the whole range) makes sure the |
| 61 | + * floor major's latest is tested too. The top major is derived from the pinned latest in `package.json` rather than a |
| 62 | + * registry lookup, so a major that was never published makes the install fail loudly — the signal to add the package |
| 63 | + * to `nonConsecutiveMajorPackages`. |
| 64 | + * |
| 65 | + * Notations that resolve to the same exact version (`1.2.3`, `=1.2.3`, `v1.2.3`) collapse to a single key, and `*` |
| 66 | + * collapses to the latest major (the same version an open-ended range resolves to), so a version is never installed |
| 67 | + * twice under different spellings. A floor that equals the pinned latest also drops the redundant top-major key, |
| 68 | + * since the pinned `package.json` proves they resolve to the same version. |
| 69 | + * |
| 70 | + * @param {string} name The module name, e.g. `mongodb`. |
| 71 | + * @param {string[]} versions The declared version entries, e.g. `['>=3.3 <5', '5', '>=6']`. |
| 72 | + * @param {Set<string>} [nonConsecutiveMajors] Module names whose majors are not contiguous; injectable for testing. |
| 73 | + * @returns {Array<{ versionKey: string, range: string }>} Ordered, de-duplicated entries. `versionKey` is the folder |
| 74 | + * suffix; `range` is the declaring entry it came from. |
| 75 | + */ |
| 76 | +function getVersionList (name, versions, nonConsecutiveMajors = nonConsecutiveMajorPackages) { |
| 77 | + /** @type {Map<string, { versionKey: string, range: string }>} */ |
| 78 | + const entries = new Map() |
| 79 | + |
| 80 | + const add = (versionKey, range) => { |
| 81 | + if (!entries.has(versionKey)) entries.set(versionKey, { versionKey, range }) |
| 82 | + } |
| 83 | + |
| 84 | + for (const range of versions) { |
| 85 | + // An empty entry is a setup mistake (a stray comma or an undefined slot); fail loudly rather than skip silently. |
| 86 | + if (!range) { |
| 87 | + throw new Error(`Empty version entry declared for '${name}'. Each declared version must be a non-empty range.`) |
| 88 | + } |
| 89 | + |
| 90 | + if (range === '*') { |
| 91 | + add(latestMajor(name), range) |
| 92 | + continue |
| 93 | + } |
| 94 | + |
| 95 | + // Exact-version notations collapse to one key so the same version is never installed twice. |
| 96 | + const exact = clean(range) |
| 97 | + if (exact) { |
| 98 | + add(exact, range) |
| 99 | + continue |
| 100 | + } |
| 101 | + |
| 102 | + const floor = coerce(range) |
| 103 | + if (!floor) throw new Error(`Invalid version range for '${name}': ${range}`) |
| 104 | + |
| 105 | + add(floor.version, range) |
| 106 | + |
| 107 | + const topMajor = highestMajor(name, range, floor.major) |
| 108 | + if (nonConsecutiveMajors.has(name)) { |
| 109 | + // Only the in-between majors were never published. The floor major and the top major both exist, so add the |
| 110 | + // latest of each (skipping the uncertain middle, which is what would make the install fail). |
| 111 | + add(String(floor.major), range) |
| 112 | + if (topMajor > floor.major) add(String(topMajor), range) |
| 113 | + } else { |
| 114 | + for (let major = floor.major; major <= topMajor; major++) { |
| 115 | + if (intersects(`>=${major}.0.0 <${major + 1}.0.0`, range)) add(String(major), range) |
| 116 | + } |
| 117 | + } |
| 118 | + } |
| 119 | + |
| 120 | + // The bare-major key for the pinned latest's major resolves to the pin itself, so when a declared floor already pins |
| 121 | + // that exact version the major key would install the same thing. Drop it. This is the only such redundancy the |
| 122 | + // pinned upper bound lets us prove; for lower majors the newest published version is unknown without the registry. |
| 123 | + const pinned = coerce(latests[name]) |
| 124 | + if (pinned && entries.has(pinned.version)) entries.delete(String(pinned.major)) |
| 125 | + |
| 126 | + return [...entries.values()] |
| 127 | +} |
| 128 | + |
| 129 | +/** |
| 130 | + * The latest major of `name` as a bare-major version key. `*` resolves here so it de-duplicates against an open-ended |
| 131 | + * range whose top resolves to the same newest version. |
| 132 | + * |
| 133 | + * @param {string} name |
| 134 | + * @returns {string} |
| 135 | + */ |
| 136 | +function latestMajor (name) { |
| 137 | + const latest = coerce(latests[name]) |
| 138 | + if (!latest) { |
| 139 | + throw new Error( |
| 140 | + `Latest version for '${name}' needs to be defined in 'packages/dd-trace/test/plugins/versions/package.json'.` |
| 141 | + ) |
| 142 | + } |
| 143 | + return String(latest.major) |
| 144 | +} |
| 145 | + |
| 146 | +/** |
| 147 | + * Highest major still spanned by `range`, capped at the pinned latest. Iterates down from the latest so the first |
| 148 | + * intersecting major is the top; nothing above the pinned latest is installed. |
| 149 | + * |
| 150 | + * @param {string} name |
| 151 | + * @param {string} range |
| 152 | + * @param {number} floorMajor |
| 153 | + * @returns {number} |
| 154 | + */ |
| 155 | +function highestMajor (name, range, floorMajor) { |
| 156 | + const latest = coerce(latests[name]) |
| 157 | + if (!latest) return floorMajor |
| 158 | + for (let major = latest.major; major > floorMajor; major--) { |
| 159 | + if (intersects(`>=${major}.0.0 <${major + 1}.0.0`, range)) return major |
| 160 | + } |
| 161 | + return floorMajor |
| 162 | +} |
| 163 | + |
| 164 | +/** |
| 165 | + * Resolve which version keys to install and test for a module, plus which key the unversioned |
| 166 | + * `versions/<name>` folder points at. `scripts/install_plugin_modules.js` and `withVersions()` both call this so the |
| 167 | + * installed folder set and the tested folder set are derived from one place and cannot drift. |
| 168 | + * |
| 169 | + * @param {object} options |
| 170 | + * @param {string} options.name The module name, e.g. `fastify`. |
| 171 | + * @param {string[]} options.declaredVersions The declared version entries to expand. |
| 172 | + * @param {boolean} [options.honourEnvRange] Whether `PACKAGE_VERSION_RANGE` applies to this module. False for sibling |
| 173 | + * externals that must stay on their declared versions while the matrix shards a different package. |
| 174 | + * @param {NodeJS.ProcessEnv} [options.env] Injectable for testing. |
| 175 | + * @returns {{ versionList: Array<{ versionKey: string, range: string }>, unversioned: string|undefined }} The ordered, |
| 176 | + * `RANGE`-filtered key set, and the key the default `versions/<name>` folder resolves to (the newest in-scope entry, |
| 177 | + * or `undefined` when nothing is in scope). |
| 178 | + */ |
| 179 | +function resolvePluginVersions ({ name, declaredVersions, honourEnvRange = true, env = process.env }) { |
| 180 | + const useEnvRange = Boolean(env.PACKAGE_VERSION_RANGE) && honourEnvRange |
| 181 | + const versions = useEnvRange ? [env.PACKAGE_VERSION_RANGE] : declaredVersions |
| 182 | + |
| 183 | + let versionList = getVersionList(name, versions) |
| 184 | + if (env.RANGE) { |
| 185 | + versionList = versionList.filter(({ versionKey }) => subset(versionKey, env.RANGE)) |
| 186 | + } |
| 187 | + |
| 188 | + // With `PACKAGE_VERSION_RANGE` the shard itself is the target, so the unversioned folder keeps the raw range even |
| 189 | + // when `RANGE` narrows the installed keys; otherwise it follows the newest in-scope key. |
| 190 | + const unversioned = useEnvRange ? env.PACKAGE_VERSION_RANGE : versionList.at(-1)?.versionKey |
| 191 | + |
| 192 | + return { versionList, unversioned } |
| 193 | +} |
| 194 | + |
43 | 195 | module.exports = { |
44 | | - getCappedRange |
| 196 | + getCappedRange, |
| 197 | + getVersionList, |
| 198 | + resolvePluginVersions, |
45 | 199 | } |
0 commit comments