@netlify/build 29.38.1 → 29.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,4 +15,5 @@ export const DEFAULT_FEATURE_FLAGS = {
15
15
  buildbot_zisi_system_log: false,
16
16
  edge_functions_cache_cli: false,
17
17
  edge_functions_system_logger: false,
18
+ netlify_build_updated_plugin_compatibility: false,
18
19
  };
@@ -1,4 +1,6 @@
1
1
  import { PackageJson } from 'read-pkg-up';
2
+ import { FeatureFlags } from '../core/feature_flags.js';
3
+ import { SystemLogger } from '../plugins_core/types.js';
2
4
  import { PluginVersion } from './list.js';
3
5
  /**
4
6
  * Retrieve the `expectedVersion` of a plugin:
@@ -11,14 +13,18 @@ import { PluginVersion } from './list.js';
11
13
  * - This is only used to print a warning message when the `compatibleVersion`
12
14
  * is older than the currently used version.
13
15
  */
14
- export declare const getExpectedVersion: ({ versions, nodeVersion, packageJson, packagePath, buildDir, pinnedVersion, }: {
16
+ export declare const getExpectedVersion: ({ versions, nodeVersion, packageJson, packageName, packagePath, buildDir, pinnedVersion, featureFlags, systemLog, authoritative, }: {
15
17
  versions: PluginVersion[];
16
18
  /** The package.json of the repository */
17
19
  packageJson: PackageJson;
20
+ packageName: string;
18
21
  packagePath?: string | undefined;
19
22
  buildDir: string;
20
23
  nodeVersion: string;
21
24
  pinnedVersion?: string | undefined;
25
+ featureFlags?: FeatureFlags | undefined;
26
+ systemLog: SystemLogger;
27
+ authoritative?: boolean | undefined;
22
28
  }) => Promise<{
23
29
  version: string;
24
30
  compatWarning: string;
@@ -15,14 +15,17 @@ const pEvery = _pEvery;
15
15
  * - This is only used to print a warning message when the `compatibleVersion`
16
16
  * is older than the currently used version.
17
17
  */
18
- export const getExpectedVersion = async function ({ versions, nodeVersion, packageJson, packagePath, buildDir, pinnedVersion, }) {
18
+ export const getExpectedVersion = async function ({ versions, nodeVersion, packageJson, packageName, packagePath, buildDir, pinnedVersion, featureFlags, systemLog, authoritative, }) {
19
19
  const { version, conditions = [] } = await getCompatibleEntry({
20
20
  versions,
21
21
  nodeVersion,
22
22
  packageJson,
23
+ packageName,
23
24
  packagePath,
24
25
  buildDir,
25
26
  pinnedVersion,
27
+ featureFlags,
28
+ systemLog: authoritative ? systemLog : undefined,
26
29
  });
27
30
  // Retrieve warning message shown when using an older version with `compatibility`
28
31
  const compatWarning = conditions.map(({ type, condition }) => CONDITIONS[type].warning(condition)).join(', ');
@@ -44,12 +47,19 @@ export const getExpectedVersion = async function ({ versions, nodeVersion, packa
44
47
  * - If there is a `pinnedVersion`, use it unless `latestVersion` matches it
45
48
  * - Otherwise, use `latestVersion`
46
49
  */
47
- const getCompatibleEntry = async function ({ versions, nodeVersion, packageJson, packagePath, buildDir, pinnedVersion, }) {
50
+ const getCompatibleEntry = async function ({ versions, nodeVersion, packageJson, packageName, packagePath, buildDir, pinnedVersion, featureFlags, systemLog = () => {
51
+ // no-op
52
+ }, }) {
48
53
  const compatibleEntry = await pLocate(versions, async ({ version, overridePinnedVersion, conditions }) => {
49
- // Detect if the overridePinnedVersion intersects with the pinned version in this case we don't care about filtering
54
+ // When there's a `pinnedVersion`, we typically pick the first version that
55
+ // matches that range. The exception is if `overridePinnedVersion` is also
56
+ // present. This property says that if the pinned version is within a given
57
+ // range, the entry that has this property can be used instead, even if its
58
+ // own version doesn't satisfy the pinned version.
50
59
  const overridesPin = Boolean(pinnedVersion && overridePinnedVersion && semver.intersects(overridePinnedVersion, pinnedVersion));
51
- // ignore versions that don't satisfy the pinned version here if a pinned version is set
52
- if (!overridesPin && pinnedVersion && !semver.satisfies(version, pinnedVersion, { includePrerelease: true })) {
60
+ // If there's a pinned version and this entry doesn't satisfy that range,
61
+ // discard it. The exception is if this entry overrides the pinned version.
62
+ if (pinnedVersion && !overridesPin && !semver.satisfies(version, pinnedVersion, { includePrerelease: true })) {
53
63
  return false;
54
64
  }
55
65
  // no conditions means nothing to filter
@@ -58,6 +68,43 @@ const getCompatibleEntry = async function ({ versions, nodeVersion, packageJson,
58
68
  }
59
69
  return await pEvery(conditions, async ({ type, condition }) => CONDITIONS[type].test(condition, { nodeVersion, packageJson, packagePath, buildDir }));
60
70
  });
61
- return (compatibleEntry ||
62
- (pinnedVersion ? { version: pinnedVersion, conditions: [] } : { version: versions[0].version, conditions: [] }));
71
+ if (compatibleEntry) {
72
+ systemLog(`Used compatible version '${compatibleEntry.version}' for plugin '${packageName}' (pinned version is ${pinnedVersion})`);
73
+ return compatibleEntry;
74
+ }
75
+ if (pinnedVersion) {
76
+ systemLog(`Used pinned version '${pinnedVersion}' for plugin '${packageName}'`);
77
+ return { version: pinnedVersion, conditions: [] };
78
+ }
79
+ const legacyFallback = { version: versions[0].version, conditions: [] };
80
+ const fallback = await getFirstCompatibleEntry({ versions, nodeVersion, packageJson, packagePath, buildDir });
81
+ if (featureFlags?.netlify_build_updated_plugin_compatibility) {
82
+ if (legacyFallback.version !== fallback.version) {
83
+ systemLog(`Detected mismatch in selected version for plugin '${packageName}': used new version of '${fallback.version}' over legacy version '${legacyFallback.version}'`);
84
+ }
85
+ return fallback;
86
+ }
87
+ if (legacyFallback.version !== fallback.version) {
88
+ systemLog(`Detected mismatch in selected version for plugin '${packageName}': used legacy version '${legacyFallback.version}' over new version '${fallback.version}'`);
89
+ }
90
+ return legacyFallback;
91
+ };
92
+ /**
93
+ * Takes a list of plugin versions and returns the first entry that satisfies
94
+ * the conditions (if any), without taking into account the pinned version.
95
+ */
96
+ const getFirstCompatibleEntry = async function ({ versions, nodeVersion, packageJson, packagePath, buildDir, }) {
97
+ const compatibleEntry = await pLocate(versions, async ({ conditions }) => {
98
+ if (conditions.length === 0) {
99
+ return true;
100
+ }
101
+ return await pEvery(conditions, async ({ type, condition }) => CONDITIONS[type].test(condition, { nodeVersion, packageJson, packagePath, buildDir }));
102
+ });
103
+ if (compatibleEntry) {
104
+ return compatibleEntry;
105
+ }
106
+ // We should never get here, because it means there are no plugin versions
107
+ // that we can install. We're keeping this here because it has been the
108
+ // default behavior for a long time, but we should look to remove it.
109
+ return { version: versions[0].version, conditions: [] };
63
110
  };
@@ -1,177 +1,298 @@
1
- import { expect, test } from 'vitest';
1
+ import { describe, expect, test } from 'vitest';
2
2
  import { getExpectedVersion } from './compatibility.js';
3
- test('`getExpectedVersion` should ignore the new major version if the version is pinned', async () => {
4
- const versions = [
5
- { version: '5.0.0', conditions: [] },
6
- { version: '4.41.2', conditions: [] },
7
- ];
8
- const { version } = await getExpectedVersion({
9
- versions,
10
- nodeVersion: '18.19.0',
11
- packageJson: {},
12
- buildDir: '/some/path',
13
- pinnedVersion: '4',
3
+ const noopSystemLog = () => {
4
+ // no-op
5
+ };
6
+ describe(`getExpectedVersion`, () => {
7
+ test('should ignore the new major version if the version is pinned', async () => {
8
+ const versions = [
9
+ { version: '5.0.0', conditions: [] },
10
+ { version: '4.41.2', conditions: [] },
11
+ ];
12
+ const { version } = await getExpectedVersion({
13
+ versions,
14
+ nodeVersion: '18.19.0',
15
+ packageJson: {},
16
+ packageName: '@netlify/cool-plugin',
17
+ buildDir: '/some/path',
18
+ pinnedVersion: '4',
19
+ systemLog: noopSystemLog,
20
+ });
21
+ expect(version).toBe('4.41.2');
14
22
  });
15
- expect(version).toBe('4.41.2');
16
- });
17
- test('`getExpectedVersion` matches prerelease versions', async () => {
18
- const versions = [
19
- { version: '5.0.0', conditions: [] },
20
- { version: '4.42.0-alpha.1', conditions: [] },
21
- { version: '4.41.2', conditions: [] },
22
- ];
23
- const { version: version1 } = await getExpectedVersion({
24
- versions,
25
- nodeVersion: '18.19.0',
26
- packageJson: {},
27
- buildDir: '/some/path',
23
+ test('matches prerelease versions', async () => {
24
+ const versions = [
25
+ { version: '5.0.0', conditions: [] },
26
+ { version: '4.42.0-alpha.1', conditions: [] },
27
+ { version: '4.41.2', conditions: [] },
28
+ ];
29
+ const { version: version1 } = await getExpectedVersion({
30
+ versions,
31
+ nodeVersion: '18.19.0',
32
+ packageJson: {},
33
+ packageName: '@netlify/cool-plugin',
34
+ buildDir: '/some/path',
35
+ systemLog: noopSystemLog,
36
+ });
37
+ const { version: version2 } = await getExpectedVersion({
38
+ versions,
39
+ nodeVersion: '18.19.0',
40
+ packageJson: {},
41
+ packageName: '@netlify/cool-plugin',
42
+ buildDir: '/some/path',
43
+ pinnedVersion: '4',
44
+ systemLog: noopSystemLog,
45
+ });
46
+ expect(version1).toBe('5.0.0');
47
+ expect(version2).toBe('4.42.0-alpha.1');
28
48
  });
29
- const { version: version2 } = await getExpectedVersion({
30
- versions,
31
- nodeVersion: '18.19.0',
32
- packageJson: {},
33
- buildDir: '/some/path',
34
- pinnedVersion: '4',
49
+ test('should retrieve a new major version if the overridePinnedVersion is specified', async () => {
50
+ const versions = [
51
+ { version: '5.0.0', conditions: [], overridePinnedVersion: '>=4.0.0' },
52
+ { version: '4.41.2', conditions: [] },
53
+ ];
54
+ const { version } = await getExpectedVersion({
55
+ versions,
56
+ nodeVersion: '18.19.0',
57
+ packageJson: {},
58
+ packageName: '@netlify/cool-plugin',
59
+ buildDir: '/some/path',
60
+ pinnedVersion: '4',
61
+ systemLog: noopSystemLog,
62
+ });
63
+ expect(version).toBe('5.0.0');
35
64
  });
36
- expect(version1).toBe('5.0.0');
37
- expect(version2).toBe('4.42.0-alpha.1');
38
- });
39
- test('`getExpectedVersion`should retrieve a new major version if the overridePinnedVersion is specified', async () => {
40
- const versions = [
41
- { version: '5.0.0', conditions: [], overridePinnedVersion: '>=4.0.0' },
42
- { version: '4.41.2', conditions: [] },
43
- ];
44
- const { version } = await getExpectedVersion({
45
- versions,
46
- nodeVersion: '18.19.0',
47
- packageJson: {},
48
- buildDir: '/some/path',
49
- pinnedVersion: '4',
65
+ test('should retrieve the plugin based on the condition of a nodeVersion', async () => {
66
+ const versions = [
67
+ {
68
+ version: '4.42.0',
69
+ conditions: [{ type: 'nodeVersion', condition: '>=18.0.0' }],
70
+ },
71
+ { version: '4.41.2', conditions: [] },
72
+ ];
73
+ const { version } = await getExpectedVersion({
74
+ versions,
75
+ nodeVersion: '17.19.0',
76
+ packageJson: {},
77
+ packageName: '@netlify/cool-plugin',
78
+ buildDir: '/some/path',
79
+ pinnedVersion: '4',
80
+ systemLog: noopSystemLog,
81
+ });
82
+ expect(version).toBe('4.41.2');
50
83
  });
51
- expect(version).toBe('5.0.0');
52
- });
53
- test('`getExpectedVersion`should retrieve the plugin based on the condition of a nodeVersion', async () => {
54
- const versions = [
55
- {
56
- version: '4.42.0',
57
- conditions: [{ type: 'nodeVersion', condition: '>=18.0.0' }],
58
- },
59
- { version: '4.41.2', conditions: [] },
60
- ];
61
- const { version } = await getExpectedVersion({
62
- versions,
63
- nodeVersion: '17.19.0',
64
- packageJson: {},
65
- buildDir: '/some/path',
66
- pinnedVersion: '4',
84
+ test('should retrieve the plugin based on conditions and feature flag due to pinned version', async () => {
85
+ const versions = [
86
+ {
87
+ version: '5.0.0-beta.1',
88
+ conditions: [
89
+ { type: 'nodeVersion', condition: '>= 18.0.0' },
90
+ { type: 'siteDependencies', condition: { next: '>=13.5.0' } },
91
+ ],
92
+ overridePinnedVersion: '>=4.0.0',
93
+ },
94
+ {
95
+ version: '4.42.0',
96
+ conditions: [{ type: 'siteDependencies', condition: { next: '>=10.0.9' } }],
97
+ },
98
+ { version: '4.41.2', conditions: [] },
99
+ {
100
+ version: '3.9.2',
101
+ conditions: [{ type: 'siteDependencies', condition: { next: '<10.0.9' } }],
102
+ },
103
+ ];
104
+ const { version: version1 } = await getExpectedVersion({
105
+ versions,
106
+ nodeVersion: '17.19.0',
107
+ packageJson: { dependencies: { next: '10.0.8' } },
108
+ packageName: '@netlify/cool-plugin',
109
+ buildDir: '/some/path',
110
+ pinnedVersion: '3',
111
+ systemLog: noopSystemLog,
112
+ });
113
+ expect(version1).toBe('3.9.2');
114
+ const { version: version2 } = await getExpectedVersion({
115
+ versions,
116
+ nodeVersion: '17.19.0',
117
+ packageJson: { dependencies: { next: '11.0.0' } },
118
+ packageName: '@netlify/cool-plugin',
119
+ buildDir: '/some/path',
120
+ pinnedVersion: '4',
121
+ systemLog: noopSystemLog,
122
+ });
123
+ expect(version2).toBe('4.42.0');
124
+ const { version: version3 } = await getExpectedVersion({
125
+ versions,
126
+ nodeVersion: '18.19.0',
127
+ packageJson: { dependencies: { next: '13.5.0' } },
128
+ packageName: '@netlify/cool-plugin',
129
+ buildDir: '/some/path',
130
+ pinnedVersion: '4',
131
+ systemLog: noopSystemLog,
132
+ });
133
+ expect(version3).toBe('5.0.0-beta.1');
67
134
  });
68
- expect(version).toBe('4.41.2');
69
- });
70
- test('`getExpectedVersion` should retrieve the plugin based on conditions and feature flag due to pinned version', async () => {
71
- const versions = [
72
- {
73
- version: '5.0.0-beta.1',
74
- conditions: [
75
- { type: 'nodeVersion', condition: '>= 18.0.0' },
76
- { type: 'siteDependencies', condition: { next: '>=13.5.0' } },
77
- ],
78
- overridePinnedVersion: '>=4.0.0',
79
- },
80
- {
81
- version: '4.42.0',
82
- conditions: [{ type: 'siteDependencies', condition: { next: '>=10.0.9' } }],
83
- },
84
- { version: '4.41.2', conditions: [] },
85
- {
86
- version: '3.9.2',
87
- conditions: [{ type: 'siteDependencies', condition: { next: '<10.0.9' } }],
88
- },
89
- ];
90
- const { version: version1 } = await getExpectedVersion({
91
- versions,
92
- nodeVersion: '17.19.0',
93
- packageJson: { dependencies: { next: '10.0.8' } },
94
- buildDir: '/some/path',
95
- pinnedVersion: '3',
135
+ test('should work with rc versions inside the siteDependencies constraints', async () => {
136
+ const versions = [
137
+ {
138
+ version: '5.0.0-beta.1',
139
+ conditions: [
140
+ { type: 'nodeVersion', condition: '>= 18.0.0' },
141
+ { type: 'siteDependencies', condition: { next: '>=13.5.0' } },
142
+ ],
143
+ overridePinnedVersion: '>=4.0.0',
144
+ },
145
+ {
146
+ version: '4.42.0',
147
+ conditions: [{ type: 'siteDependencies', condition: { next: '>=10.0.9' } }],
148
+ },
149
+ { version: '4.41.2', conditions: [] },
150
+ {
151
+ version: '3.9.2',
152
+ conditions: [{ type: 'siteDependencies', condition: { next: '<10.0.9' } }],
153
+ },
154
+ ];
155
+ const { version } = await getExpectedVersion({
156
+ versions,
157
+ nodeVersion: '18.19.0',
158
+ packageJson: { dependencies: { next: '14.1.1-canary.36' } },
159
+ packageName: '@netlify/cool-plugin',
160
+ buildDir: '/some/path',
161
+ pinnedVersion: '4',
162
+ systemLog: noopSystemLog,
163
+ });
164
+ expect(version).toBe('5.0.0-beta.1');
96
165
  });
97
- expect(version1).toBe('3.9.2');
98
- const { version: version2 } = await getExpectedVersion({
99
- versions,
100
- nodeVersion: '17.19.0',
101
- packageJson: { dependencies: { next: '11.0.0' } },
102
- buildDir: '/some/path',
103
- pinnedVersion: '4',
166
+ test('should retrieve the plugin based on conditions and feature flag due to pinned version', async () => {
167
+ const versions = [
168
+ {
169
+ version: '5.0.0-beta.1',
170
+ conditions: [
171
+ { type: 'nodeVersion', condition: '>= 18.0.0' },
172
+ { type: 'siteDependencies', condition: { next: '>=13.5.0' } },
173
+ ],
174
+ overridePinnedVersion: '>=4.0.0',
175
+ },
176
+ { version: '4.41.2', conditions: [] },
177
+ {
178
+ version: '3.9.2',
179
+ conditions: [{ type: 'siteDependencies', condition: { next: '<10.0.9' } }],
180
+ },
181
+ ];
182
+ const { version: version1 } = await getExpectedVersion({
183
+ versions,
184
+ nodeVersion: '20.0.0',
185
+ packageJson: { dependencies: { next: '14.0.0' } },
186
+ packageName: '@netlify/cool-plugin',
187
+ buildDir: '/some/path',
188
+ pinnedVersion: '4',
189
+ systemLog: noopSystemLog,
190
+ });
191
+ expect(version1).toBe('5.0.0-beta.1');
192
+ // out of range
193
+ const { version: version2 } = await getExpectedVersion({
194
+ versions,
195
+ nodeVersion: '20.0.0',
196
+ packageJson: { dependencies: { next: '13.0.0' } },
197
+ packageName: '@netlify/cool-plugin',
198
+ buildDir: '/some/path',
199
+ pinnedVersion: '4',
200
+ systemLog: noopSystemLog,
201
+ });
202
+ expect(version2).toBe('4.41.2');
104
203
  });
105
- expect(version2).toBe('4.42.0');
106
- const { version: version3 } = await getExpectedVersion({
107
- versions,
108
- nodeVersion: '18.19.0',
109
- packageJson: { dependencies: { next: '13.5.0' } },
110
- buildDir: '/some/path',
111
- pinnedVersion: '4',
204
+ test('matches the first entry that satisfies the constraints, even if it also matches another entry further down with more specific constraints', async () => {
205
+ const versions = [
206
+ { version: '4.41.2', conditions: [] },
207
+ {
208
+ version: '5.0.0-beta.1',
209
+ conditions: [
210
+ { type: 'nodeVersion', condition: '>= 18.0.0' },
211
+ { type: 'siteDependencies', condition: { next: '>=13.5.0' } },
212
+ ],
213
+ overridePinnedVersion: '>=4.0.0',
214
+ },
215
+ {
216
+ version: '3.9.2',
217
+ conditions: [{ type: 'siteDependencies', condition: { next: '<10.0.9' } }],
218
+ },
219
+ ];
220
+ const { version } = await getExpectedVersion({
221
+ versions,
222
+ nodeVersion: '20.0.0',
223
+ packageJson: { dependencies: { next: '14.0.0' } },
224
+ packageName: '@netlify/cool-plugin',
225
+ buildDir: '/some/path',
226
+ pinnedVersion: '4',
227
+ systemLog: noopSystemLog,
228
+ });
229
+ expect(version).toBe('4.41.2');
112
230
  });
113
- expect(version3).toBe('5.0.0-beta.1');
114
- });
115
- test('`getExpectedVersion` should work with rc versions inside the siteDependencies constraints', async () => {
116
- const versions = [
117
- {
118
- version: '5.0.0-beta.1',
119
- conditions: [
120
- { type: 'nodeVersion', condition: '>= 18.0.0' },
121
- { type: 'siteDependencies', condition: { next: '>=13.5.0' } },
122
- ],
123
- overridePinnedVersion: '>=4.0.0',
124
- },
125
- {
126
- version: '4.42.0',
127
- conditions: [{ type: 'siteDependencies', condition: { next: '>=10.0.9' } }],
128
- },
129
- { version: '4.41.2', conditions: [] },
130
- {
131
- version: '3.9.2',
132
- conditions: [{ type: 'siteDependencies', condition: { next: '<10.0.9' } }],
133
- },
134
- ];
135
- const { version } = await getExpectedVersion({
136
- versions,
137
- nodeVersion: '18.19.0',
138
- packageJson: { dependencies: { next: '14.1.1-canary.36' } },
139
- buildDir: '/some/path',
140
- pinnedVersion: '4',
141
- });
142
- expect(version).toBe('5.0.0-beta.1');
143
- });
144
- test('`getExpectedVersion` should retrieve the plugin based on conditions and feature flag due to pinned version', async () => {
145
- const versions = [
146
- {
147
- version: '5.0.0-beta.1',
148
- conditions: [
149
- { type: 'nodeVersion', condition: '>= 18.0.0' },
150
- { type: 'siteDependencies', condition: { next: '>=13.5.0' } },
151
- ],
152
- overridePinnedVersion: '>=4.0.0',
153
- },
154
- { version: '4.41.2', conditions: [] },
155
- {
156
- version: '3.9.2',
157
- conditions: [{ type: 'siteDependencies', condition: { next: '<10.0.9' } }],
158
- },
159
- ];
160
- const { version: version1 } = await getExpectedVersion({
161
- versions,
162
- nodeVersion: '20.0.0',
163
- packageJson: { dependencies: { next: '14.0.0' } },
164
- buildDir: '/some/path',
165
- pinnedVersion: '4',
231
+ test('if no pinned version is set, it matches the first version regardless of whether its requirements match the conditions (legacy behavior)', async () => {
232
+ const versions = [
233
+ {
234
+ version: '5.0.0-beta.1',
235
+ conditions: [
236
+ { type: 'nodeVersion', condition: '>= 18.0.0' },
237
+ { type: 'siteDependencies', condition: { next: '>=13.5.0' } },
238
+ ],
239
+ overridePinnedVersion: '>=4.0.0',
240
+ },
241
+ { version: '4.41.2', conditions: [] },
242
+ {
243
+ version: '3.9.2',
244
+ conditions: [{ type: 'siteDependencies', condition: { next: '<10.0.9' } }],
245
+ },
246
+ ];
247
+ const logMessages = [];
248
+ const { version } = await getExpectedVersion({
249
+ versions,
250
+ nodeVersion: '20.0.0',
251
+ packageJson: { dependencies: { next: '12.0.0' } },
252
+ packageName: '@netlify/cool-plugin',
253
+ buildDir: '/some/path',
254
+ systemLog: (message) => {
255
+ logMessages.push(message);
256
+ },
257
+ authoritative: true,
258
+ });
259
+ expect(logMessages.length).toBe(1);
260
+ expect(logMessages[0]).toBe(`Detected mismatch in selected version for plugin '@netlify/cool-plugin': used legacy version '5.0.0-beta.1' over new version '4.41.2'`);
261
+ expect(version).toBe('5.0.0-beta.1');
166
262
  });
167
- expect(version1).toBe('5.0.0-beta.1');
168
- // out of range
169
- const { version: version2 } = await getExpectedVersion({
170
- versions,
171
- nodeVersion: '20.0.0',
172
- packageJson: { dependencies: { next: '13.0.0' } },
173
- buildDir: '/some/path',
174
- pinnedVersion: '4',
263
+ test('if no pinned version is set, it matches the first version whose requirements match the conditions', async () => {
264
+ const versions = [
265
+ {
266
+ version: '5.0.0-beta.1',
267
+ conditions: [
268
+ { type: 'nodeVersion', condition: '>= 18.0.0' },
269
+ { type: 'siteDependencies', condition: { next: '>=13.5.0' } },
270
+ ],
271
+ overridePinnedVersion: '>=4.0.0',
272
+ },
273
+ { version: '4.41.2', conditions: [] },
274
+ {
275
+ version: '3.9.2',
276
+ conditions: [{ type: 'siteDependencies', condition: { next: '<10.0.9' } }],
277
+ },
278
+ ];
279
+ const logMessages = [];
280
+ const { version } = await getExpectedVersion({
281
+ versions,
282
+ nodeVersion: '20.0.0',
283
+ packageJson: { dependencies: { next: '12.0.0' } },
284
+ packageName: '@netlify/cool-plugin',
285
+ buildDir: '/some/path',
286
+ systemLog: (message) => {
287
+ logMessages.push(message);
288
+ },
289
+ featureFlags: {
290
+ netlify_build_updated_plugin_compatibility: true,
291
+ },
292
+ authoritative: true,
293
+ });
294
+ expect(logMessages.length).toBe(1);
295
+ expect(logMessages[0]).toBe(`Detected mismatch in selected version for plugin '@netlify/cool-plugin': used new version of '4.41.2' over legacy version '5.0.0-beta.1'`);
296
+ expect(version).toBe('4.41.2');
175
297
  });
176
- expect(version2).toBe('4.41.2');
177
298
  });
@@ -1,4 +1,4 @@
1
- export declare const addExpectedVersions: ({ pluginsOptions, autoPluginsDir, packageJson, packagePath, debug, logs, buildDir, testOpts, featureFlags, }: {
1
+ export declare const addExpectedVersions: ({ pluginsOptions, autoPluginsDir, packageJson, packagePath, debug, logs, buildDir, testOpts, featureFlags, systemLog, }: {
2
2
  pluginsOptions: any;
3
3
  autoPluginsDir: any;
4
4
  packageJson: any;
@@ -8,4 +8,5 @@ export declare const addExpectedVersions: ({ pluginsOptions, autoPluginsDir, pac
8
8
  buildDir: any;
9
9
  testOpts: any;
10
10
  featureFlags: any;
11
+ systemLog: any;
11
12
  }) => Promise<any>;
@@ -7,7 +7,7 @@ import { getPluginsList } from './list.js';
7
7
  // When using plugins in our official list, those are installed in .netlify/plugins/
8
8
  // We ensure that the last version that's been approved is always the one being used.
9
9
  // We also ensure that the plugin is our official list.
10
- export const addExpectedVersions = async function ({ pluginsOptions, autoPluginsDir, packageJson, packagePath, debug, logs, buildDir, testOpts, featureFlags, }) {
10
+ export const addExpectedVersions = async function ({ pluginsOptions, autoPluginsDir, packageJson, packagePath, debug, logs, buildDir, testOpts, featureFlags, systemLog, }) {
11
11
  if (!pluginsOptions.some(needsExpectedVersion)) {
12
12
  return pluginsOptions;
13
13
  }
@@ -21,10 +21,11 @@ export const addExpectedVersions = async function ({ pluginsOptions, autoPlugins
21
21
  buildDir,
22
22
  featureFlags,
23
23
  testOpts,
24
+ systemLog,
24
25
  })));
25
26
  };
26
27
  /** Any `pluginOptions` with `expectedVersion` set will be automatically installed */
27
- const addExpectedVersion = async function ({ pluginsList, autoPluginsDir, packageJson, packagePath, pluginOptions, pluginOptions: { packageName, pluginPath, loadedFrom, nodeVersion, pinnedVersion }, buildDir, featureFlags, testOpts, }) {
28
+ const addExpectedVersion = async function ({ pluginsList, autoPluginsDir, packageJson, packagePath, pluginOptions, pluginOptions: { packageName, pluginPath, loadedFrom, nodeVersion, pinnedVersion }, buildDir, featureFlags, testOpts, systemLog, }) {
28
29
  if (!needsExpectedVersion(pluginOptions)) {
29
30
  return pluginOptions;
30
31
  }
@@ -36,8 +37,28 @@ const addExpectedVersion = async function ({ pluginsList, autoPluginsDir, packag
36
37
  const versions = filterVersions(unfilteredVersions, featureFlags);
37
38
  const [{ version: latestVersion, migrationGuide }] = versions;
38
39
  const [{ version: expectedVersion }, { version: compatibleVersion, compatWarning }] = await Promise.all([
39
- getExpectedVersion({ versions, nodeVersion, packageJson, packagePath, buildDir, pinnedVersion }),
40
- getExpectedVersion({ versions, nodeVersion, packageJson, packagePath, buildDir }),
40
+ getExpectedVersion({
41
+ versions,
42
+ nodeVersion,
43
+ packageJson,
44
+ packageName,
45
+ packagePath,
46
+ buildDir,
47
+ pinnedVersion,
48
+ featureFlags,
49
+ systemLog,
50
+ authoritative: true,
51
+ }),
52
+ getExpectedVersion({
53
+ versions,
54
+ nodeVersion,
55
+ packageJson,
56
+ packageName,
57
+ packagePath,
58
+ buildDir,
59
+ featureFlags,
60
+ systemLog,
61
+ }),
41
62
  ]);
42
63
  const isMissing = await isMissingVersion({ autoPluginsDir, packageName, pluginPath, loadedFrom, expectedVersion });
43
64
  return {
@@ -33,6 +33,7 @@ export const resolvePluginsPath = async function ({ pluginsOptions, siteInfo, bu
33
33
  buildDir,
34
34
  testOpts,
35
35
  featureFlags,
36
+ systemLog,
36
37
  });
37
38
  const pluginsOptionsE = await handleMissingPlugins({
38
39
  pluginsOptions: pluginsOptionsD,
@@ -1,2 +1,2 @@
1
- import { CoreStep } from '../types.js';
1
+ import { type CoreStep } from '../types.js';
2
2
  export declare const uploadBlobs: CoreStep;
@@ -3,8 +3,7 @@ import { getDeployStore } from '@netlify/blobs';
3
3
  import pMap from 'p-map';
4
4
  import semver from 'semver';
5
5
  import { log, logError } from '../../log/logger.js';
6
- import { scanForBlobs } from '../../utils/blobs.js';
7
- import { getKeysToUpload, getFileWithMetadata } from './utils.js';
6
+ import { getFileWithMetadata, getKeysToUpload, scanForBlobs } from '../../utils/blobs.js';
8
7
  const coreStep = async function ({ debug, logs, deployId, buildDir, quiet, packagePath, constants: { SITE_ID, NETLIFY_API_TOKEN, NETLIFY_API_HOST }, }) {
9
8
  // This should never happen due to the condition check
10
9
  if (!deployId || !NETLIFY_API_TOKEN) {
@@ -20,10 +19,8 @@ const coreStep = async function ({ debug, logs, deployId, buildDir, quiet, packa
20
19
  };
21
20
  // If we don't have native `fetch` in the global scope, add a polyfill.
22
21
  if (semver.lt(nodeVersion, '18.0.0')) {
23
- const nodeFetch = await import('node-fetch');
24
- // @ts-expect-error The types between `node-fetch` and the native `fetch`
25
- // are not a 100% match, even though the APIs are mostly compatible.
26
- storeOpts.fetch = nodeFetch.default;
22
+ const nodeFetch = (await import('node-fetch')).default;
23
+ storeOpts.fetch = nodeFetch;
27
24
  }
28
25
  const blobs = await scanForBlobs(buildDir, packagePath);
29
26
  // We checked earlier, but let's be extra safe
@@ -49,15 +46,14 @@ const coreStep = async function ({ debug, logs, deployId, buildDir, quiet, packa
49
46
  if (!quiet) {
50
47
  log(logs, `Uploading ${keys.length} blobs to deploy store...`);
51
48
  }
52
- const uploadBlob = async (key) => {
53
- if (debug && !quiet) {
54
- log(logs, `- Uploading blob ${key}`, { indent: true });
55
- }
56
- const { data, metadata } = await getFileWithMetadata(blobs.directory, key);
57
- await blobStore.set(key, data, { metadata });
58
- };
59
49
  try {
60
- await pMap(keys, uploadBlob, { concurrency: 10 });
50
+ await pMap(keys, async (key) => {
51
+ if (debug && !quiet) {
52
+ log(logs, `- Uploading blob ${key}`, { indent: true });
53
+ }
54
+ const { data, metadata } = await getFileWithMetadata(blobs.directory, key);
55
+ await blobStore.set(key, data, { metadata });
56
+ }, { concurrency: 10 });
61
57
  }
62
58
  catch (err) {
63
59
  logError(logs, `Error uploading blobs to deploy store: ${err.message}`);
@@ -0,0 +1,2 @@
1
+ import { type CoreStep } from '../types.js';
2
+ export declare const devUploadBlobs: CoreStep;
@@ -0,0 +1,75 @@
1
+ import { version as nodeVersion } from 'node:process';
2
+ import { getDeployStore } from '@netlify/blobs';
3
+ import pMap from 'p-map';
4
+ import semver from 'semver';
5
+ import { log, logError } from '../../log/logger.js';
6
+ import { getFileWithMetadata, getKeysToUpload, scanForBlobs } from '../../utils/blobs.js';
7
+ const coreStep = async function ({ debug, logs, deployId, buildDir, quiet, packagePath, constants: { SITE_ID, NETLIFY_API_TOKEN, NETLIFY_API_HOST }, }) {
8
+ // This should never happen due to the condition check
9
+ if (!deployId || !NETLIFY_API_TOKEN) {
10
+ return {};
11
+ }
12
+ // for cli deploys with `netlify deploy --build` the `NETLIFY_API_HOST` is undefined
13
+ const apiHost = NETLIFY_API_HOST || 'api.netlify.com';
14
+ const storeOpts = {
15
+ siteID: SITE_ID,
16
+ deployID: deployId,
17
+ token: NETLIFY_API_TOKEN,
18
+ apiURL: `https://${apiHost}`,
19
+ };
20
+ // If we don't have native `fetch` in the global scope, add a polyfill.
21
+ if (semver.lt(nodeVersion, '18.0.0')) {
22
+ const nodeFetch = (await import('node-fetch')).default;
23
+ storeOpts.fetch = nodeFetch;
24
+ }
25
+ const blobs = await scanForBlobs(buildDir, packagePath);
26
+ // We checked earlier, but let's be extra safe
27
+ if (blobs === null) {
28
+ if (!quiet) {
29
+ log(logs, 'No blobs to upload to deploy store.');
30
+ }
31
+ return {};
32
+ }
33
+ // If using the deploy config API, configure the store to use the region that
34
+ // was configured for the deploy.
35
+ if (!blobs.isLegacyDirectory) {
36
+ storeOpts.experimentalRegion = 'auto';
37
+ }
38
+ const blobStore = getDeployStore(storeOpts);
39
+ const keys = await getKeysToUpload(blobs.directory);
40
+ if (keys.length === 0) {
41
+ if (!quiet) {
42
+ log(logs, 'No blobs to upload to deploy store.');
43
+ }
44
+ return {};
45
+ }
46
+ if (!quiet) {
47
+ log(logs, `Uploading ${keys.length} blobs to deploy store...`);
48
+ }
49
+ try {
50
+ await pMap(keys, async (key) => {
51
+ if (debug && !quiet) {
52
+ log(logs, `- Uploading blob ${key}`, { indent: true });
53
+ }
54
+ const { data, metadata } = await getFileWithMetadata(blobs.directory, key);
55
+ await blobStore.set(key, data, { metadata });
56
+ }, { concurrency: 10 });
57
+ }
58
+ catch (err) {
59
+ logError(logs, `Error uploading blobs to deploy store: ${err.message}`);
60
+ throw new Error(`Failed while uploading blobs to deploy store`);
61
+ }
62
+ if (!quiet) {
63
+ log(logs, `Done uploading blobs to deploy store.`);
64
+ }
65
+ return {};
66
+ };
67
+ const deployAndBlobsPresent = async ({ deployId, buildDir, packagePath, constants: { NETLIFY_API_TOKEN }, }) => Boolean(NETLIFY_API_TOKEN && deployId && (await scanForBlobs(buildDir, packagePath)));
68
+ export const devUploadBlobs = {
69
+ event: 'onDev',
70
+ coreStep,
71
+ coreStepId: 'dev_blobs_upload',
72
+ coreStepName: 'Uploading blobs',
73
+ coreStepDescription: () => 'Uploading blobs to development deploy store',
74
+ condition: deployAndBlobsPresent,
75
+ };
package/lib/steps/get.js CHANGED
@@ -4,6 +4,7 @@ import { uploadBlobs } from '../plugins_core/blobs_upload/index.js';
4
4
  import { buildCommandCore } from '../plugins_core/build_command.js';
5
5
  import { deploySite } from '../plugins_core/deploy/index.js';
6
6
  import { applyDeployConfig } from '../plugins_core/deploy_config/index.js';
7
+ import { devUploadBlobs } from '../plugins_core/dev_blobs_upload/index.js';
7
8
  import { bundleEdgeFunctions } from '../plugins_core/edge_functions/index.js';
8
9
  import { bundleFunctions } from '../plugins_core/functions/index.js';
9
10
  import { preCleanup } from '../plugins_core/pre_cleanup/index.js';
@@ -32,7 +33,7 @@ export const getDevSteps = function (command, steps, eventHandlers) {
32
33
  coreStepDescription: () => 'Run command for local development',
33
34
  };
34
35
  const eventSteps = getEventSteps(eventHandlers);
35
- const sortedSteps = sortSteps([preDevCleanup, ...steps, eventSteps, devCommandStep], DEV_EVENTS);
36
+ const sortedSteps = sortSteps([preDevCleanup, ...steps, devUploadBlobs, eventSteps, devCommandStep], DEV_EVENTS);
36
37
  const events = getEvents(sortedSteps);
37
38
  return { steps: sortedSteps, events };
38
39
  };
@@ -1,3 +1,4 @@
1
+ /// <reference types="node" resolution-mode="require"/>
1
2
  /** Retrieve the absolute path of the deploy scoped internal blob directories */
2
3
  export declare const getBlobsDirs: (buildDir: string, packagePath?: string) => string[];
3
4
  /**
@@ -13,3 +14,10 @@ export declare const scanForBlobs: (buildDir: string, packagePath?: string) => P
13
14
  directory: string;
14
15
  isLegacyDirectory: boolean;
15
16
  } | null>;
17
+ /** Given output directory, find all file paths to upload excluding metadata files */
18
+ export declare const getKeysToUpload: (blobsDir: string) => Promise<string[]>;
19
+ /** Read a file and its metadata file from the blobs directory */
20
+ export declare const getFileWithMetadata: (blobsDir: string, key: string) => Promise<{
21
+ data: Buffer;
22
+ metadata: Record<string, string>;
23
+ }>;
@@ -1,11 +1,12 @@
1
- import { resolve } from 'node:path';
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
2
3
  import { fdir } from 'fdir';
3
4
  const LEGACY_BLOBS_PATH = '.netlify/blobs/deploy';
4
5
  const DEPLOY_CONFIG_BLOBS_PATH = '.netlify/deploy/v1/blobs/deploy';
5
6
  /** Retrieve the absolute path of the deploy scoped internal blob directories */
6
7
  export const getBlobsDirs = (buildDir, packagePath) => [
7
- resolve(buildDir, packagePath || '', DEPLOY_CONFIG_BLOBS_PATH),
8
- resolve(buildDir, packagePath || '', LEGACY_BLOBS_PATH),
8
+ path.resolve(buildDir, packagePath || '', DEPLOY_CONFIG_BLOBS_PATH),
9
+ path.resolve(buildDir, packagePath || '', LEGACY_BLOBS_PATH),
9
10
  ];
10
11
  /**
11
12
  * Detect if there are any blobs to upload, and if so, what directory they're
@@ -17,7 +18,7 @@ export const getBlobsDirs = (buildDir, packagePath) => [
17
18
  * @returns
18
19
  */
19
20
  export const scanForBlobs = async function (buildDir, packagePath) {
20
- const blobsDir = resolve(buildDir, packagePath || '', DEPLOY_CONFIG_BLOBS_PATH);
21
+ const blobsDir = path.resolve(buildDir, packagePath || '', DEPLOY_CONFIG_BLOBS_PATH);
21
22
  const blobsDirScan = await new fdir().onlyCounts().crawl(blobsDir).withPromise();
22
23
  if (blobsDirScan.files > 0) {
23
24
  return {
@@ -25,7 +26,7 @@ export const scanForBlobs = async function (buildDir, packagePath) {
25
26
  isLegacyDirectory: false,
26
27
  };
27
28
  }
28
- const legacyBlobsDir = resolve(buildDir, packagePath || '', LEGACY_BLOBS_PATH);
29
+ const legacyBlobsDir = path.resolve(buildDir, packagePath || '', LEGACY_BLOBS_PATH);
29
30
  const legacyBlobsDirScan = await new fdir().onlyCounts().crawl(legacyBlobsDir).withPromise();
30
31
  if (legacyBlobsDirScan.files > 0) {
31
32
  return {
@@ -35,3 +36,46 @@ export const scanForBlobs = async function (buildDir, packagePath) {
35
36
  }
36
37
  return null;
37
38
  };
39
+ const METADATA_PREFIX = '$';
40
+ const METADATA_SUFFIX = '.json';
41
+ /** Given output directory, find all file paths to upload excluding metadata files */
42
+ export const getKeysToUpload = async (blobsDir) => {
43
+ const files = await new fdir()
44
+ .withRelativePaths() // we want the relative path from the blobsDir
45
+ .filter((fpath) => !path.basename(fpath).startsWith(METADATA_PREFIX))
46
+ .crawl(blobsDir)
47
+ .withPromise();
48
+ // normalize the path separators to all use the forward slash
49
+ return files.map((f) => f.split(path.sep).join('/'));
50
+ };
51
+ /** Read a file and its metadata file from the blobs directory */
52
+ export const getFileWithMetadata = async (blobsDir, key) => {
53
+ const contentPath = path.join(blobsDir, key);
54
+ const dirname = path.dirname(key);
55
+ const basename = path.basename(key);
56
+ const metadataPath = path.join(blobsDir, dirname, `${METADATA_PREFIX}${basename}${METADATA_SUFFIX}`);
57
+ const [data, metadata] = await Promise.all([readFile(contentPath), readMetadata(metadataPath)]).catch((err) => {
58
+ throw new Error(`Failed while reading '${key}' and its metadata: ${err.message}`);
59
+ });
60
+ return { data, metadata };
61
+ };
62
+ const readMetadata = async (metadataPath) => {
63
+ let metadataFile;
64
+ try {
65
+ metadataFile = await readFile(metadataPath, { encoding: 'utf8' });
66
+ }
67
+ catch (err) {
68
+ if (err.code === 'ENOENT') {
69
+ // no metadata file found, that's ok
70
+ return {};
71
+ }
72
+ throw err;
73
+ }
74
+ try {
75
+ return JSON.parse(metadataFile);
76
+ }
77
+ catch {
78
+ // Normalize the error message
79
+ throw new Error(`Error parsing metadata file '${metadataPath}'`);
80
+ }
81
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netlify/build",
3
- "version": "29.38.1",
3
+ "version": "29.39.0",
4
4
  "description": "Netlify build module",
5
5
  "type": "module",
6
6
  "exports": "./lib/index.js",
@@ -68,17 +68,17 @@
68
68
  "license": "MIT",
69
69
  "dependencies": {
70
70
  "@bugsnag/js": "^7.0.0",
71
- "@netlify/blobs": "^7.2.0",
71
+ "@netlify/blobs": "^7.3.0",
72
72
  "@netlify/cache-utils": "^5.1.5",
73
73
  "@netlify/config": "^20.12.1",
74
74
  "@netlify/edge-bundler": "11.3.0",
75
75
  "@netlify/framework-info": "^9.8.11",
76
- "@netlify/functions-utils": "^5.2.52",
76
+ "@netlify/functions-utils": "^5.2.53",
77
77
  "@netlify/git-utils": "^5.1.1",
78
78
  "@netlify/opentelemetry-utils": "^1.1.0",
79
79
  "@netlify/plugins-list": "^6.77.0",
80
80
  "@netlify/run-utils": "^5.1.1",
81
- "@netlify/zip-it-and-ship-it": "9.31.0",
81
+ "@netlify/zip-it-and-ship-it": "9.31.1",
82
82
  "@sindresorhus/slugify": "^2.0.0",
83
83
  "ansi-escapes": "^6.0.0",
84
84
  "chalk": "^5.0.0",
@@ -164,5 +164,5 @@
164
164
  "engines": {
165
165
  "node": "^14.16.0 || >=16.0.0"
166
166
  },
167
- "gitHead": "decb312a4cbaa1a18c2787b28d8e83b32b34dbe5"
167
+ "gitHead": "1594f6b29e2fcaf9b57fc0938af0d352d74374c1"
168
168
  }
@@ -1,8 +0,0 @@
1
- /// <reference types="node" resolution-mode="require"/>
2
- /** Given output directory, find all file paths to upload excluding metadata files */
3
- export declare function getKeysToUpload(blobsDir: string): Promise<string[]>;
4
- /** Read a file and its metadata file from the blobs directory */
5
- export declare function getFileWithMetadata(blobsDir: string, key: string): Promise<{
6
- data: Buffer;
7
- metadata: Record<string, string>;
8
- }>;
@@ -1,46 +0,0 @@
1
- import { readFile } from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { fdir } from 'fdir';
4
- const METADATA_PREFIX = '$';
5
- const METADATA_SUFFIX = '.json';
6
- /** Given output directory, find all file paths to upload excluding metadata files */
7
- export async function getKeysToUpload(blobsDir) {
8
- const files = await new fdir()
9
- .withRelativePaths() // we want the relative path from the blobsDir
10
- .filter((fpath) => !path.basename(fpath).startsWith(METADATA_PREFIX))
11
- .crawl(blobsDir)
12
- .withPromise();
13
- // normalize the path separators to all use the forward slash
14
- return files.map((f) => f.split(path.sep).join('/'));
15
- }
16
- /** Read a file and its metadata file from the blobs directory */
17
- export async function getFileWithMetadata(blobsDir, key) {
18
- const contentPath = path.join(blobsDir, key);
19
- const dirname = path.dirname(key);
20
- const basename = path.basename(key);
21
- const metadataPath = path.join(blobsDir, dirname, `${METADATA_PREFIX}${basename}${METADATA_SUFFIX}`);
22
- const [data, metadata] = await Promise.all([readFile(contentPath), readMetadata(metadataPath)]).catch((err) => {
23
- throw new Error(`Failed while reading '${key}' and its metadata: ${err.message}`);
24
- });
25
- return { data, metadata };
26
- }
27
- async function readMetadata(metadataPath) {
28
- let metadataFile;
29
- try {
30
- metadataFile = await readFile(metadataPath, { encoding: 'utf8' });
31
- }
32
- catch (err) {
33
- if (err.code === 'ENOENT') {
34
- // no metadata file found, that's ok
35
- return {};
36
- }
37
- throw err;
38
- }
39
- try {
40
- return JSON.parse(metadataFile);
41
- }
42
- catch {
43
- // Normalize the error message
44
- throw new Error(`Error parsing metadata file '${metadataPath}'`);
45
- }
46
- }