dependabot-npm_and_yarn 0.391.0 → 0.392.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e0783178e3793f20749b70800cb29d01e579ca1b483f378463d151d6ad880bf1
4
- data.tar.gz: 54cf02c69a72bc84f73d700d750daa93faa9edec9de6f5a339a47031921d5ac2
3
+ metadata.gz: 1f8ae1a24dcec46ab349429b68c517aba99fa9308f3e020aa53db169c4f1614c
4
+ data.tar.gz: 15b8a2c1f6dc838d1b4638b5771e59237c3995720cc2b9a2543e09d1adf9477a
5
5
  SHA512:
6
- metadata.gz: 7365a4460a19d39621a87895bb9af7d2b146521a42d6bbe0c1896ef3fcfd38cc268370430a5121d2623ac0275606b8f939a0d531e9b800cd51bed8af0933f6e4
7
- data.tar.gz: 258cda2ea6cd40d761209c4687941a935b0da6ce36c9bd9ff71304ebe0ee67671a28a54ebf0d2b591b60c0c56bbb96ec2d7766d4e766e5db262ccffd4f3a08ba
6
+ metadata.gz: ace7c85c19a0ee524f22b71afbc4f0fe8c40d93751ccae53517f652fcec920cb73bdc13d08a22958002d6ac70abfa8da2f4ac7454da7df53a28202b0cc9fe6e0
7
+ data.tar.gz: d3c9658cce6589964732bff6236b0dfc40821de0cf523c28598d7f93d34e5defa3e2b1fc020b3a59bceb2d5ef8be5c45fc58a82ef2b3180f8ce34805ffa4bc26
@@ -30,16 +30,25 @@ module Dependabot
30
30
  dependencies: T::Array[Dependabot::Dependency],
31
31
  dependency_files: T::Array[Dependabot::DependencyFile],
32
32
  credentials: T::Array[Credential],
33
- security_updates_only: T::Boolean
33
+ security_updates_only: T::Boolean,
34
+ release_age_days: T.nilable(Integer)
34
35
  )
35
36
  .void
36
37
  end
37
- def initialize(lockfile:, dependencies:, dependency_files:, credentials:, security_updates_only: false)
38
+ def initialize(
39
+ lockfile:,
40
+ dependencies:,
41
+ dependency_files:,
42
+ credentials:,
43
+ security_updates_only: false,
44
+ release_age_days: nil
45
+ )
38
46
  @lockfile = lockfile
39
47
  @dependencies = dependencies
40
48
  @dependency_files = dependency_files
41
49
  @credentials = credentials
42
50
  @security_updates_only = security_updates_only
51
+ @release_age_days = release_age_days
43
52
  end
44
53
 
45
54
  sig { returns(Dependabot::DependencyFile) }
@@ -343,7 +352,7 @@ module Dependabot
343
352
 
344
353
  NativeHelpers.run_npm8_subdependency_update_command(
345
354
  dependency_names,
346
- security_updates_only: security_updates_only?
355
+ min_release_age_arg: effective_min_release_age_arg
347
356
  )
348
357
 
349
358
  updated_content = File.read(lockfile_basename)
@@ -354,7 +363,7 @@ module Dependabot
354
363
  # npm audit fix exits non-zero when vulnerabilities remain, so we
355
364
  # rescue and use whatever lockfile changes it managed to make.
356
365
  begin
357
- NativeHelpers.run_npm_audit_fix_command(security_updates_only: security_updates_only?)
366
+ NativeHelpers.run_npm_audit_fix_command(min_release_age_arg: effective_min_release_age_arg)
358
367
  sub_dependencies.each { |dep| dep.metadata[:audit_fix_used] = true }
359
368
  rescue SharedHelpers::HelperSubprocessFailed
360
369
  Dependabot.logger.info("npm audit fix failed or partially fixed — continuing with any changes made")
@@ -404,9 +413,8 @@ module Dependabot
404
413
  ]
405
414
 
406
415
  command_args << "--save-optional" if has_optional_dependencies
407
- # Override any min-release-age set in .npmrc: security fixes must not be
408
- # blocked by a release-age gate the user configured for regular updates.
409
- command_args << "--min-release-age=0" if security_updates_only?
416
+ min_release_age_arg = effective_min_release_age_arg
417
+ command_args << min_release_age_arg if min_release_age_arg
410
418
 
411
419
  command = command_args.join(" ")
412
420
 
@@ -419,13 +427,67 @@ module Dependabot
419
427
  ]
420
428
 
421
429
  fingerprint_args << "--save-optional" if has_optional_dependencies
422
- fingerprint_args << "--min-release-age=0" if security_updates_only?
430
+ # The cooldown day value varies per job, so keep it out of the fingerprint.
431
+ fingerprint_args << fingerprint_min_release_age_arg(min_release_age_arg) if min_release_age_arg
423
432
 
424
433
  fingerprint = fingerprint_args.join(" ")
425
434
 
426
435
  Helpers.run_npm_command(command, fingerprint: fingerprint)
427
436
  end
428
437
 
438
+ # Returns the `--min-release-age` argument to pass to npm (`install`,
439
+ # `update`, or `audit fix`), or nil when none applies. Security updates pass
440
+ # `=0` so a release-age gate never blocks a fix (this intentionally
441
+ # overrides any `.npmrc` gate). Regular updates pass the dependabot.yml
442
+ # cooldown floor (in days) so npm holds transitive dependencies back to
443
+ # versions at least that old. Requires npm >= 11.10 (the updater image
444
+ # ships a supporting version).
445
+ #
446
+ # When the repo also sets an explicit `min-release-age` in `.npmrc`, the
447
+ # longest release-age wins: the cooldown floor is only injected on the CLI
448
+ # when it exceeds the user's configured value, otherwise the user's (equal
449
+ # or longer) gate is left untouched so neither policy is silently weakened.
450
+ sig { returns(T.nilable(String)) }
451
+ def effective_min_release_age_arg
452
+ return nil unless npm_supports_min_release_age?
453
+
454
+ return "--min-release-age=0" if security_updates_only?
455
+
456
+ effective = Helpers.higher_release_age_gate(@release_age_days, npmrc_min_release_age)
457
+ return nil unless effective
458
+
459
+ "--min-release-age=#{effective}"
460
+ end
461
+
462
+ # Whether the npm that will run supports `--min-release-age` (npm 11.10+).
463
+ # npm runs through Corepack, so a repo pinned to an older npm via
464
+ # `packageManager` would reject the flag; gate it out for those. Memoized so
465
+ # the version subprocess runs at most once per update.
466
+ sig { returns(T::Boolean) }
467
+ def npm_supports_min_release_age?
468
+ @npm_supports_min_release_age = T.let(@npm_supports_min_release_age, T.nilable(T::Boolean))
469
+ return @npm_supports_min_release_age unless @npm_supports_min_release_age.nil?
470
+
471
+ @npm_supports_min_release_age = Helpers.npm_supports_min_release_age?
472
+ end
473
+
474
+ sig { params(arg: String).returns(String) }
475
+ def fingerprint_min_release_age_arg(arg)
476
+ arg == "--min-release-age=0" ? arg : "--min-release-age=<days>"
477
+ end
478
+
479
+ # The `min-release-age` (in days) configured across the repo's `.npmrc`
480
+ # files, or nil when unset. A value we cannot parse as a bare integer is
481
+ # reported as Float::INFINITY so an explicit-but-non-numeric user gate is
482
+ # never overridden by the cooldown floor.
483
+ sig { returns(T.nilable(T.any(Integer, Float))) }
484
+ def npmrc_min_release_age
485
+ Helpers.max_configured_release_age(
486
+ dependency_files,
487
+ [Helpers::ReleaseAgeGateSetting.new(filename: ".npmrc", key: "min-release-age", separator: "=")]
488
+ )
489
+ end
490
+
429
491
  sig { params(dependency: Dependabot::Dependency).returns(String) }
430
492
  def npm_install_args(dependency)
431
493
  git_requirement = dependency.requirements.find { |req| req.source_string("type") == "git" }
@@ -4,6 +4,7 @@
4
4
  require "dependabot/npm_and_yarn/helpers"
5
5
  require "dependabot/npm_and_yarn/package/registry_finder"
6
6
  require "dependabot/npm_and_yarn/registry_parser"
7
+ require "dependabot/npm_and_yarn/version"
7
8
  require "dependabot/shared_helpers"
8
9
 
9
10
  module Dependabot
@@ -22,7 +23,8 @@ module Dependabot
22
23
  dependency_files: T::Array[Dependabot::DependencyFile],
23
24
  repo_contents_path: T.nilable(String),
24
25
  credentials: T::Array[Dependabot::Credential],
25
- security_updates_only: T::Boolean
26
+ security_updates_only: T::Boolean,
27
+ release_age_days: T.nilable(Integer)
26
28
  ).void
27
29
  end
28
30
  def initialize(
@@ -30,13 +32,15 @@ module Dependabot
30
32
  dependency_files:,
31
33
  repo_contents_path:,
32
34
  credentials:,
33
- security_updates_only: false
35
+ security_updates_only: false,
36
+ release_age_days: nil
34
37
  )
35
38
  @dependencies = dependencies
36
39
  @dependency_files = dependency_files
37
40
  @repo_contents_path = repo_contents_path
38
41
  @credentials = credentials
39
42
  @security_updates_only = security_updates_only
43
+ @release_age_days = release_age_days
40
44
  @error_handler = T.let(
41
45
  PnpmErrorHandler.new(
42
46
  dependencies: dependencies,
@@ -92,6 +96,18 @@ module Dependabot
92
96
 
93
97
  IRRESOLVABLE_PACKAGE = "ERR_PNPM_NO_MATCHING_VERSION"
94
98
  INVALID_REQUIREMENT = "ERR_PNPM_SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER"
99
+
100
+ # pnpm introduced the `minimumReleaseAge` release-age gate in 10.16 and the
101
+ # `minimumReleaseAgeStrict` toggle in 11.0; older versions ignore them.
102
+ PNPM_MINIMUM_RELEASE_AGE_VERSION = "10.16"
103
+ PNPM_MINIMUM_RELEASE_AGE_STRICT_VERSION = "11.0"
104
+ # pnpm 10.x ignores minimumReleaseAge when shared-workspace-lockfile is
105
+ # disabled (pnpm/pnpm#10008); the fix ships in pnpm 11.
106
+ PNPM_WORKSPACE_RELEASE_AGE_FIX_VERSION = "11.0"
107
+ # pnpm 11 stopped reading non-registry settings (e.g. minimum-release-age)
108
+ # from .npmrc, so a .npmrc release-age gate is only effective on pnpm 10.x.
109
+ PNPM_NPMRC_RELEASE_AGE_DROPPED_VERSION = "11.0"
110
+
95
111
  UNREACHABLE_GIT = %r{Command failed with exit code 128: git ls-remote (?<url>.*github\.com/[^/]+/[^ ]+)}
96
112
  UNREACHABLE_GIT_V8 = %r{ERR_PNPM_FETCH_404[ [^:print]]+GET (?<url>https://codeload\.github\.com/[^/]+/[^/]+)/}
97
113
  FORBIDDEN_PACKAGE = /ERR_PNPM_FETCH_403[ [^:print]]+GET (?<dependency_url>.*): Forbidden - 403/
@@ -207,35 +223,242 @@ module Dependabot
207
223
 
208
224
  cmd = "update #{dependency_updates} --lockfile-only --no-save -r"
209
225
  fingerprint = "update <dependency_updates> --lockfile-only --no-save -r"
210
- if security_updates_only?
211
- # Override any minimumReleaseAge set in pnpm-workspace.yaml: security fixes must not be
212
- # blocked by a release-age gate the user configured for regular updates.
213
- cmd += " --config.minimumReleaseAge=0 --config.minimumReleaseAgeStrict=false"
214
- fingerprint += " --config.minimumReleaseAge=0 --config.minimumReleaseAgeStrict=false"
215
- end
216
- Helpers.run_pnpm_command(cmd, fingerprint: fingerprint)
226
+ run_pnpm_command_with_release_age_gate(cmd, fingerprint)
217
227
  end
218
228
 
219
229
  sig { returns(T.nilable(String)) }
220
230
  def run_pnpm_install
221
- cmd = "install --lockfile-only"
222
- if security_updates_only?
223
- # Override any minimumReleaseAge set in pnpm-workspace.yaml: security fixes must not be
224
- # blocked by a release-age gate the user configured for regular updates.
225
- cmd += " --config.minimumReleaseAge=0 --config.minimumReleaseAgeStrict=false"
231
+ # `install --lockfile-only` has no dynamic content, so it needs no
232
+ # fingerprint of its own. Passing nil lets the release-age gate handle
233
+ # fingerprinting only when it appends the (dynamic) minimumReleaseAge.
234
+ run_pnpm_command_with_release_age_gate("install --lockfile-only")
235
+ end
236
+
237
+ # pnpm's `minimumReleaseAge` needs the registry `time` field, which is
238
+ # absent from the abbreviated metadata pnpm uses for some packages during
239
+ # `pnpm update`, raising ERR_PNPM_MISSING_TIME. When that happens for a
240
+ # cooldown-derived gate we retry without it so the update still succeeds,
241
+ # rather than failing the whole job. Security bypass (`=0`) never triggers
242
+ # this because it disables the age lookup entirely.
243
+ sig { params(cmd: String, fingerprint: T.nilable(String)).returns(T.nilable(String)) }
244
+ def run_pnpm_command_with_release_age_gate(cmd, fingerprint = nil)
245
+ gate = release_age_gate_config
246
+ return execute_pnpm_command(cmd, fingerprint) unless gate
247
+
248
+ gate_fingerprint = security_updates_only? ? gate : fingerprint_minimum_release_age_config
249
+ gated_fingerprint = "#{fingerprint || cmd} #{gate_fingerprint}"
250
+ begin
251
+ execute_pnpm_command("#{cmd} #{gate}", gated_fingerprint)
252
+ rescue SharedHelpers::HelperSubprocessFailed => e
253
+ raise if security_updates_only? || !e.message.include?("ERR_PNPM_MISSING_TIME")
254
+
255
+ Dependabot.logger.warn(
256
+ "pnpm could not apply the cooldown release-age gate because the registry metadata " \
257
+ "is missing the \"time\" field (ERR_PNPM_MISSING_TIME); retrying without the " \
258
+ "transitive cooldown gate so the update is not blocked."
259
+ )
260
+ execute_pnpm_command(cmd, fingerprint)
261
+ end
262
+ end
263
+
264
+ sig { params(cmd: String, fingerprint: T.nilable(String)).returns(T.nilable(String)) }
265
+ def execute_pnpm_command(cmd, fingerprint)
266
+ if fingerprint
267
+ Helpers.run_pnpm_command(cmd, fingerprint: fingerprint)
268
+ else
269
+ Helpers.run_pnpm_command(cmd)
270
+ end
271
+ end
272
+
273
+ # Returns the pnpm `--config.minimumReleaseAge` arguments to apply, or nil.
274
+ # Security updates disable the gate (`=0`); regular updates apply the
275
+ # dependabot.yml cooldown floor (in minutes). `minimumReleaseAge` was added
276
+ # in pnpm 10.16, so older pnpm silently ignores it — rather than give a
277
+ # false guarantee we skip the gate (and warn) when the running pnpm is too
278
+ # old to enforce it.
279
+ sig { returns(T.nilable(String)) }
280
+ def release_age_gate_config
281
+ if !security_updates_only? && @release_age_days&.positive? && !pnpm_supports_minimum_release_age?
282
+ Dependabot.logger.warn(
283
+ "pnpm #{pnpm_version || '(unknown version)'} does not support minimumReleaseAge " \
284
+ "(added in pnpm 10.16); the release-age cooldown cannot be enforced for transitive " \
285
+ "dependencies on this pnpm version."
286
+ )
287
+ return nil
226
288
  end
227
- Helpers.run_pnpm_command(cmd)
289
+
290
+ minutes = effective_release_age_minutes
291
+ return nil if minutes.nil?
292
+
293
+ # Security updates pass minimumReleaseAge=0 unconditionally: older pnpm
294
+ # ignores it, and a transient version-probe failure must not leave a native
295
+ # gate active and block the fix.
296
+ return minimum_release_age_gate_args(minutes) if security_updates_only?
297
+
298
+ minimum_release_age_gate_args(minutes)
299
+ end
300
+
301
+ # The pnpm `minimumReleaseAge` value (in minutes) to enforce for this
302
+ # update, independent of the pnpm version: 0 to bypass the gate for
303
+ # security fixes (which must not be blocked by a release-age gate the user
304
+ # configured for regular updates), the dependabot.yml cooldown floor for
305
+ # regular updates, or nil when no gate applies.
306
+ #
307
+ # When the repo also sets an explicit `minimumReleaseAge` in
308
+ # pnpm-workspace.yaml (or `.npmrc`), the longest release-age wins: the
309
+ # cooldown floor is only injected when it exceeds the user's configured
310
+ # value, otherwise the user's (equal or longer) gate is left untouched so
311
+ # neither policy is silently weakened (dependabot/dependabot-core#13165).
312
+ sig { returns(T.nilable(Integer)) }
313
+ def effective_release_age_minutes
314
+ return 0 if security_updates_only?
315
+
316
+ cooldown_minutes = @release_age_days && (@release_age_days * Helpers::MINUTES_PER_DAY)
317
+ Helpers.higher_release_age_gate(cooldown_minutes, pnpm_configured_minimum_release_age)
318
+ end
319
+
320
+ # Builds the pnpm `--config.minimumReleaseAge` args for `minutes`, adding
321
+ # the strict toggle only when appropriate (see `disable_strict_release_age?`).
322
+ sig { params(minutes: Integer).returns(String) }
323
+ def minimum_release_age_gate_args(minutes)
324
+ args = "--config.minimumReleaseAge=#{minutes}"
325
+ args += " --config.minimumReleaseAgeStrict=false" if disable_strict_release_age?
326
+ args
327
+ end
328
+
329
+ # pnpm defaults `minimumReleaseAgeStrict` to *on* when `minimumReleaseAge`
330
+ # is set via the CLI, which fails resolution when no version satisfies the
331
+ # window and is incompatible with the `--no-save` update command. We
332
+ # disable strict for Dependabot's CLI override on pnpm >= 11.0, where the
333
+ # toggle exists. Equal-or-longer native gates remain untouched because no
334
+ # CLI override is added for them.
335
+ sig { returns(T::Boolean) }
336
+ def disable_strict_release_age?
337
+ pnpm_supports_minimum_release_age_strict?
338
+ end
339
+
340
+ sig { returns(String) }
341
+ def fingerprint_minimum_release_age_config
342
+ args = "--config.minimumReleaseAge=<minutes>"
343
+ args += " --config.minimumReleaseAgeStrict=false" if disable_strict_release_age?
344
+ args
345
+ end
346
+
347
+ # The concrete pnpm version that will run, memoized (including a nil result)
348
+ # so the version subprocess runs at most once per update.
349
+ sig { returns(T.nilable(Dependabot::Version)) }
350
+ def pnpm_version
351
+ return @pnpm_version if defined?(@pnpm_version)
352
+
353
+ @pnpm_version = T.let(Helpers.pnpm_version, T.nilable(Dependabot::Version))
354
+ end
355
+
356
+ sig { returns(T::Boolean) }
357
+ def pnpm_supports_minimum_release_age?
358
+ version = pnpm_version
359
+ return false if version.nil? || version < Version.new(PNPM_MINIMUM_RELEASE_AGE_VERSION)
360
+
361
+ if pnpm_shared_workspace_lockfile_disabled? &&
362
+ version < Version.new(PNPM_WORKSPACE_RELEASE_AGE_FIX_VERSION)
363
+ Dependabot.logger.warn(
364
+ "pnpm #{version} ignores minimumReleaseAge when shared-workspace-lockfile is disabled " \
365
+ "(pnpm/pnpm#10008); the release-age cooldown cannot be enforced. Upgrade to pnpm 11+."
366
+ )
367
+ return false
368
+ end
369
+
370
+ true
371
+ end
372
+
373
+ sig { returns(T::Boolean) }
374
+ def pnpm_supports_minimum_release_age_strict?
375
+ version = pnpm_version
376
+ !version.nil? && version >= Version.new(PNPM_MINIMUM_RELEASE_AGE_STRICT_VERSION)
377
+ end
378
+
379
+ # pnpm 10.x ignores `minimumReleaseAge` when `shared-workspace-lockfile` is
380
+ # disabled (pnpm/pnpm#10008), so the cooldown cannot be enforced there.
381
+ sig { returns(T::Boolean) }
382
+ def pnpm_shared_workspace_lockfile_disabled?
383
+ dependency_files.any? do |file|
384
+ case File.basename(file.name)
385
+ when "pnpm-workspace.yaml"
386
+ yaml_boolean_setting(file.content.to_s, "shared-workspace-lockfile", ":") == false
387
+ when ".npmrc"
388
+ yaml_boolean_setting(file.content.to_s, "shared-workspace-lockfile", "=") == false
389
+ else
390
+ false
391
+ end
392
+ end
393
+ end
394
+
395
+ # Reads a YAML/INI boolean `key`, returning true/false, or nil when absent or
396
+ # non-boolean. Handles optionally quoted keys/values (`"key": True`), boolean
397
+ # casing, and trailing comments so a valid native setting is never misread.
398
+ # The last occurrence wins, matching how pnpm/INI resolve a repeated key.
399
+ sig { params(content: String, key: String, separator: String).returns(T.nilable(T::Boolean)) }
400
+ def yaml_boolean_setting(content, key, separator)
401
+ quoted_key = /["']?#{Regexp.escape(key)}["']?/
402
+ presence = /^\s*#{quoted_key}\s*#{Regexp.escape(separator)}/
403
+ last_line = content.lines.reverse_each.find { |line| line.match?(presence) }
404
+ return unless last_line
405
+
406
+ # npmrc/INI treats both `#` and `;` as comment delimiters; YAML uses `#`.
407
+ comment_chars = separator == "=" ? "#;" : "#"
408
+ match = last_line.match(
409
+ /^\s*#{quoted_key}\s*#{Regexp.escape(separator)}\s*["']?(\w+)["']?\s*(?:[#{comment_chars}].*)?$/
410
+ )
411
+ return unless match
412
+
413
+ case T.must(match[1]).downcase
414
+ when "true" then true
415
+ when "false" then false
416
+ end
417
+ end
418
+
419
+ # The largest `minimumReleaseAge` (in minutes) the repo configures for pnpm,
420
+ # read from pnpm-workspace.yaml (`minimumReleaseAge:`) or `.npmrc`
421
+ # (`minimum-release-age=`), or nil when unset. A value we cannot parse as a
422
+ # bare integer is reported as Float::INFINITY so an explicit-but-non-numeric
423
+ # user gate is never overridden by the cooldown floor.
424
+ sig { returns(T.nilable(T.any(Integer, Float))) }
425
+ def pnpm_configured_minimum_release_age
426
+ settings = [
427
+ Helpers::ReleaseAgeGateSetting.new(
428
+ filename: "pnpm-workspace.yaml", key: "minimumReleaseAge", separator: ":"
429
+ )
430
+ ]
431
+ # pnpm 11+ ignores non-registry settings in .npmrc, so a .npmrc
432
+ # `minimum-release-age` is only an effective user gate on pnpm 10.x.
433
+ # Counting it on pnpm 11 would wrongly suppress the cooldown CLI override
434
+ # while pnpm resolves with no age gate.
435
+ if pnpm_reads_npmrc_release_age?
436
+ settings << Helpers::ReleaseAgeGateSetting.new(
437
+ filename: ".npmrc", key: "minimum-release-age", separator: "="
438
+ )
439
+ end
440
+
441
+ Helpers.max_configured_release_age(dependency_files, settings)
442
+ end
443
+
444
+ sig { returns(T::Boolean) }
445
+ def pnpm_reads_npmrc_release_age?
446
+ version = pnpm_version
447
+ !version.nil? && version < Version.new(PNPM_NPMRC_RELEASE_AGE_DROPPED_VERSION)
228
448
  end
229
449
 
230
450
  # Tries `pnpm update --depth Infinity <dep>` for each dependency as a
231
451
  # first-tier fallback when the regular update is a no-op (typically
232
452
  # transitive deps not listed in any package.json), without relying on
233
- # audit fixes that may modify manifests on older pnpm versions.
453
+ # audit fixes that may modify manifests on older pnpm versions. It is
454
+ # routed through the release-age gate so the fallback cannot bypass the
455
+ # transitive dependency cooldown.
234
456
  sig { void }
235
457
  def run_pnpm_deep_update_fallback
236
458
  recursive = workspace_files.any?
237
459
  dependencies.each do |dep|
238
- NativeHelpers.run_pnpm_deep_update_command(dep.name, recursive: recursive)
460
+ cmd, fingerprint = NativeHelpers.pnpm_deep_update_command(dep.name, recursive: recursive)
461
+ run_pnpm_command_with_release_age_gate(cmd, fingerprint)
239
462
  dep.metadata[:deep_update_used] = true
240
463
  end
241
464
  rescue SharedHelpers::HelperSubprocessFailed
@@ -253,7 +476,8 @@ module Dependabot
253
476
  package_json_snapshots = Dir.glob("**/package.json").to_h { |f| [f, File.read(f)] }
254
477
 
255
478
  begin
256
- NativeHelpers.run_pnpm_audit_fix_command
479
+ cmd, fingerprint = NativeHelpers.pnpm_audit_fix_command
480
+ run_pnpm_command_with_release_age_gate(cmd, fingerprint)
257
481
  run_pnpm_install
258
482
 
259
483
  manifest_changed = package_json_snapshots.any? { |f, c| File.read(f) != c }
@@ -31,7 +31,8 @@ module Dependabot
31
31
  dependency_files: T::Array[Dependabot::DependencyFile],
32
32
  repo_contents_path: T.nilable(String),
33
33
  credentials: T::Array[Dependabot::Credential],
34
- security_updates_only: T::Boolean
34
+ security_updates_only: T::Boolean,
35
+ release_age_days: T.nilable(Integer)
35
36
  )
36
37
  .void
37
38
  end
@@ -40,13 +41,15 @@ module Dependabot
40
41
  dependency_files:,
41
42
  repo_contents_path:,
42
43
  credentials:,
43
- security_updates_only: false
44
+ security_updates_only: false,
45
+ release_age_days: nil
44
46
  )
45
47
  @dependencies = dependencies
46
48
  @dependency_files = dependency_files
47
49
  @repo_contents_path = repo_contents_path
48
50
  @credentials = credentials
49
51
  @security_updates_only = security_updates_only
52
+ @release_age_days = release_age_days
50
53
  @error_handler = T.let(
51
54
  YarnErrorHandler.new(
52
55
  dependencies: dependencies,
@@ -345,40 +348,92 @@ module Dependabot
345
348
  ]
346
349
 
347
350
  original_content = File.read(yarn_lock.name)
348
- Helpers.run_yarn_commands(*commands)
351
+ Helpers.run_yarn_commands(*commands, env: yarn_time_gate_env)
349
352
 
350
353
  updated_content = File.read(yarn_lock.name)
351
354
  if updated_content == original_content && Dependabot::Experiments.enabled?(:enable_audit_fix_fallback)
352
- begin
353
- NativeHelpers.run_yarn_audit_fix_command
354
- dep.metadata[:audit_fix_used] = true
355
- rescue SharedHelpers::HelperSubprocessFailed
356
- Dependabot.logger.info(
357
- "yarn npm audit --fix failed or partially fixed — continuing with any changes made"
358
- )
359
- end
355
+ run_yarn_audit_fix_fallback(dep)
360
356
  updated_content = File.read(yarn_lock.name)
361
357
  end
362
358
 
363
359
  { yarn_lock.name => updated_content }
364
360
  end
365
361
 
362
+ # Fallback for transitive deps where add/dedupe/remove is a no-op. Threads
363
+ # the release-age gate env so the audit-fix resolve honours the same cooldown
364
+ # (and security `=0` bypass) as the primary commands.
365
+ sig { params(dep: Dependabot::Dependency).void }
366
+ def run_yarn_audit_fix_fallback(dep)
367
+ NativeHelpers.run_yarn_audit_fix_command(env: yarn_time_gate_env)
368
+ dep.metadata[:audit_fix_used] = true
369
+ rescue SharedHelpers::HelperSubprocessFailed
370
+ Dependabot.logger.info(
371
+ "yarn npm audit --fix failed or partially fixed — continuing with any changes made"
372
+ )
373
+ end
374
+
366
375
  sig { returns(T::Boolean) }
367
376
  def security_updates_only?
368
377
  @security_updates_only
369
378
  end
370
379
 
371
- # Returns an env hash that disables Yarn Berry's npmMinimalAgeGate for security updates.
372
- # npmMinimalAgeGate was introduced in Yarn 4.10.0. Setting the corresponding
373
- # YARN_NPM_MINIMAL_AGE_GATE env var on older Yarn Berry releases (or Yarn classic) raises
374
- # "Unrecognized or legacy configuration settings found: npmMinimalAgeGate" and aborts the
375
- # install, so we gate on a version check.
380
+ # Returns an env hash that sets Yarn Berry's YARN_NPM_MINIMAL_AGE_GATE.
381
+ # Security updates set it to 0 so a release-age gate never blocks a fix
382
+ # (this intentionally overrides any `.yarnrc.yml` gate). Regular updates set
383
+ # it to the dependabot.yml cooldown floor (in minutes) so yarn holds
384
+ # transitive dependencies back to versions at least that old. For regular
385
+ # updates an equal-or-longer `npmMinimalAgeGate` in .yarnrc.yml takes
386
+ # precedence, so it is left untouched.
387
+ #
388
+ # npmMinimalAgeGate was introduced in Yarn 4.10.0. Setting the env var on
389
+ # older Yarn Berry releases (or Yarn classic) raises "Unrecognized or legacy
390
+ # configuration settings found: npmMinimalAgeGate" and aborts the install,
391
+ # so we gate on a version check.
392
+ #
393
+ # Memoized (including a nil result): the value is deterministic for the
394
+ # updater's lifetime and this is invoked from several yarn command sites,
395
+ # so we avoid running `yarn --version` (and emitting duplicate log lines)
396
+ # more than once per update.
376
397
  sig { returns(T.nilable(T::Hash[String, String])) }
377
398
  def yarn_time_gate_env
378
- return nil unless security_updates_only?
379
- return nil unless Helpers.yarn_berry_supports_minimal_age_gate?
399
+ return @yarn_time_gate_env if defined?(@yarn_time_gate_env)
380
400
 
381
- { "YARN_NPM_MINIMAL_AGE_GATE" => "0" }
401
+ gate_value = yarn_minimal_age_gate_value
402
+ env = if gate_value && Helpers.yarn_berry_supports_minimal_age_gate?
403
+ { "YARN_NPM_MINIMAL_AGE_GATE" => gate_value }
404
+ end
405
+ @yarn_time_gate_env = T.let(env, T.nilable(T::Hash[String, String]))
406
+ end
407
+
408
+ # Returns the YARN_NPM_MINIMAL_AGE_GATE value (in minutes) for a regular
409
+ # update, or nil when none applies. Security updates pass "0" so a
410
+ # release-age gate never blocks a fix (this intentionally overrides any
411
+ # `.yarnrc.yml` gate).
412
+ #
413
+ # When the repo also sets an explicit `npmMinimalAgeGate` in `.yarnrc.yml`,
414
+ # the longest release-age wins: the cooldown floor is only injected via the
415
+ # env var when it exceeds the user's configured value, otherwise the user's
416
+ # (equal or longer) gate is left untouched so neither policy is silently
417
+ # weakened.
418
+ sig { returns(T.nilable(String)) }
419
+ def yarn_minimal_age_gate_value
420
+ return "0" if security_updates_only?
421
+
422
+ cooldown_minutes = @release_age_days && (@release_age_days * Helpers::MINUTES_PER_DAY)
423
+ effective = Helpers.higher_release_age_gate(cooldown_minutes, yarnrc_minimal_age_gate)
424
+ effective&.to_s
425
+ end
426
+
427
+ # The `npmMinimalAgeGate` (in minutes) configured across the repo's
428
+ # `.yarnrc.yml` files, or nil when unset. A value we cannot parse as a bare
429
+ # integer is reported as Float::INFINITY so an explicit-but-non-numeric user
430
+ # gate is never overridden by the cooldown floor.
431
+ sig { returns(T.nilable(T.any(Integer, Float))) }
432
+ def yarnrc_minimal_age_gate
433
+ Helpers.max_configured_release_age(
434
+ dependency_files,
435
+ [Helpers::ReleaseAgeGateSetting.new(filename: ".yarnrc.yml", key: "npmMinimalAgeGate", separator: ":")]
436
+ )
382
437
  end
383
438
 
384
439
  sig { returns(String) }
@@ -6,6 +6,7 @@ require "dependabot/file_updaters/base"
6
6
  require "dependabot/file_updaters/vendor_updater"
7
7
  require "dependabot/file_updaters/artifact_updater"
8
8
  require "dependabot/errors"
9
+ require "dependabot/package/release_cooldown_options"
9
10
  require "dependabot/npm_and_yarn/dependency_files_filterer"
10
11
  require "dependabot/npm_and_yarn/sub_dependency_files_filterer"
11
12
  require "sorbet-runtime"
@@ -435,6 +436,33 @@ module Dependabot
435
436
  )
436
437
  end
437
438
 
439
+ # The number of days from the dependabot.yml `cooldown` config to apply as
440
+ # a release-age floor for *transitive* dependencies. npm, pnpm and yarn each
441
+ # enforce this natively at install time, which is the only point at which
442
+ # Dependabot can constrain the versions the package manager resolves for the
443
+ # transitive tree. Returns nil for security updates (which must never be
444
+ # blocked by a release-age gate) or when no positive cooldown is configured.
445
+ # `default_days` is used because the native gates are a single global value
446
+ # and cannot express per-semver-type days or include/exclude patterns.
447
+ #
448
+ # The gate is also skipped when none of the dependencies being updated are
449
+ # subject to the cooldown (per its `include`/`exclude` patterns), so an
450
+ # update the user explicitly opted out of cooldown for is not gated.
451
+ sig { returns(T.nilable(Integer)) }
452
+ def cooldown_release_age_days
453
+ return nil if options.fetch(:security_updates_only, false)
454
+
455
+ cooldown = T.cast(
456
+ options[:update_cooldown],
457
+ T.nilable(Dependabot::Package::ReleaseCooldownOptions)
458
+ )
459
+ return nil if cooldown.nil?
460
+ return nil unless dependencies.any? { |dep| cooldown.included?(dep.name) }
461
+
462
+ days = cooldown.default_days
463
+ days.positive? ? days : nil
464
+ end
465
+
438
466
  sig { returns(Dependabot::NpmAndYarn::FileUpdater::YarnLockfileUpdater) }
439
467
  def yarn_lockfile_updater
440
468
  @yarn_lockfile_updater ||= T.let(
@@ -443,7 +471,8 @@ module Dependabot
443
471
  dependency_files: dependency_files,
444
472
  repo_contents_path: repo_contents_path,
445
473
  credentials: credentials,
446
- security_updates_only: options.fetch(:security_updates_only, false) ? true : false
474
+ security_updates_only: options.fetch(:security_updates_only, false) ? true : false,
475
+ release_age_days: cooldown_release_age_days
447
476
  ),
448
477
  T.nilable(Dependabot::NpmAndYarn::FileUpdater::YarnLockfileUpdater)
449
478
  )
@@ -457,7 +486,8 @@ module Dependabot
457
486
  dependency_files: dependency_files,
458
487
  repo_contents_path: repo_contents_path,
459
488
  credentials: credentials,
460
- security_updates_only: options.fetch(:security_updates_only, false) ? true : false
489
+ security_updates_only: options.fetch(:security_updates_only, false) ? true : false,
490
+ release_age_days: cooldown_release_age_days
461
491
  ),
462
492
  T.nilable(Dependabot::NpmAndYarn::FileUpdater::PnpmLockfileUpdater)
463
493
  )
@@ -474,7 +504,8 @@ module Dependabot
474
504
  dependencies: dependencies,
475
505
  dependency_files: dependency_files,
476
506
  credentials: credentials,
477
- security_updates_only: options.fetch(:security_updates_only, false) ? true : false
507
+ security_updates_only: options.fetch(:security_updates_only, false) ? true : false,
508
+ release_age_days: cooldown_release_age_days
478
509
  )
479
510
  end
480
511
 
@@ -50,6 +50,9 @@ module Dependabot
50
50
  NPM_V6 = 6
51
51
  NPM_DEFAULT_VERSION = NPM_V11
52
52
 
53
+ # Minimum npm version that supports the `--min-release-age` CLI flag.
54
+ NPM_MINIMUM_RELEASE_AGE_VERSION = "11.10.0"
55
+
53
56
  # PNPM Version Constants
54
57
  PNPM_V10 = 10
55
58
  PNPM_V9 = 9
@@ -70,6 +73,91 @@ module Dependabot
70
73
  SUPPORTED_COREPACK_PACKAGE_MANAGERS = %w(npm yarn pnpm).freeze
71
74
  COREPACK_SIGNATURE_METADATA_ERROR = "No compatible signature found in package metadata"
72
75
 
76
+ # pnpm's `minimumReleaseAge` and yarn's `npmMinimalAgeGate` are expressed in
77
+ # minutes, whereas dependabot.yml cooldown and npm's `min-release-age` use
78
+ # days. Used to convert a cooldown floor (days) into the minutes those gates
79
+ # expect.
80
+ MINUTES_PER_DAY = 1440
81
+
82
+ # Resolves the release-age gate value a lockfile updater should enforce for a
83
+ # regular update, applying "highest precedence wins" between the Dependabot
84
+ # cooldown floor and any explicit native gate the user configured (both given
85
+ # in the same package-manager unit). Returns the value to pass on the CLI/env
86
+ # (overriding the user's config) when the cooldown is longer, or nil to leave
87
+ # the user's own (equal-or-longer) gate — or the absence of one — untouched.
88
+ # A non-numeric user gate should be passed as Float::INFINITY so it is never
89
+ # overridden. Security updates handle their own `=0` bypass and must not use
90
+ # this method.
91
+ sig do
92
+ params(cooldown: T.nilable(Integer), user_gate: T.nilable(T.any(Integer, Float))).returns(T.nilable(Integer))
93
+ end
94
+ def self.higher_release_age_gate(cooldown, user_gate)
95
+ return nil if cooldown.nil? || !cooldown.positive?
96
+ return nil if user_gate && cooldown <= user_gate
97
+
98
+ cooldown
99
+ end
100
+
101
+ # Describes where a native release-age gate can be configured: the file it
102
+ # may appear in, the setting key, and the separator between key and value
103
+ # ("=" for npmrc/ini-style files, ":" for YAML). Passed to
104
+ # `max_configured_release_age` so each ecosystem parses its user-configured
105
+ # gate through the same logic.
106
+ class ReleaseAgeGateSetting < T::Struct
107
+ const :filename, String
108
+ const :key, String
109
+ const :separator, String
110
+ end
111
+
112
+ # The largest explicitly-configured native release-age gate across the repo's
113
+ # dependency files, or nil when none is set. Each file whose basename matches
114
+ # one of `settings` is parsed for a bare integer value; a present-but-non-
115
+ # numeric value is reported as Float::INFINITY so an explicit user gate is
116
+ # never overridden by the cooldown floor (see `higher_release_age_gate`).
117
+ sig do
118
+ params(
119
+ dependency_files: T::Array[DependencyFile],
120
+ settings: T::Array[ReleaseAgeGateSetting]
121
+ ).returns(T.nilable(T.any(Integer, Float)))
122
+ end
123
+ def self.max_configured_release_age(dependency_files, settings)
124
+ values = dependency_files.filter_map do |file|
125
+ setting = settings.find { |candidate| candidate.filename == File.basename(file.name) }
126
+ next unless setting
127
+
128
+ configured_release_age(file.content.to_s, setting)
129
+ end
130
+ values.max
131
+ end
132
+
133
+ # Resolves the effective release-age value a single file sets for `setting`,
134
+ # or nil when the key is absent. npmrc/INI and YAML both let the *last*
135
+ # occurrence of a key win, so we read the last matching line rather than the
136
+ # first (e.g. `min-release-age=30` then `min-release-age=3` resolves to 3). A
137
+ # present-but-non-numeric value is reported as Float::INFINITY.
138
+ sig { params(content: String, setting: ReleaseAgeGateSetting).returns(T.nilable(T.any(Integer, Float))) }
139
+ def self.configured_release_age(content, setting)
140
+ key = Regexp.escape(setting.key)
141
+ separator = Regexp.escape(setting.separator)
142
+ # Match an optionally quoted key (`"minimumReleaseAge":`) so a valid quoted
143
+ # YAML key is not treated as absent.
144
+ quoted_key = /["']?#{key}["']?/
145
+ presence = /^\s*#{quoted_key}\s*#{separator}/
146
+ # Allow an optionally quoted value and an optional trailing comment
147
+ # (e.g. `minimumReleaseAge: "4320" # 3 days`). npmrc/INI treats both `#` and
148
+ # `;` as comment delimiters; YAML uses `#` only. Otherwise a quoted or
149
+ # commented value is treated as non-numeric (Float::INFINITY).
150
+ comment_chars = setting.separator == "=" ? "#;" : "#"
151
+ value = /^\s*#{quoted_key}\s*#{separator}\s*["']?(\d+)["']?\s*(?:[#{comment_chars}].*)?$/
152
+
153
+ last_line = content.lines.reverse_each.find { |line| line.match?(presence) }
154
+ return unless last_line
155
+
156
+ match = last_line.match(value)
157
+ match ? T.must(match[1]).to_i : Float::INFINITY
158
+ end
159
+ private_class_method :configured_release_age
160
+
73
161
  sig { params(lockfile: T.nilable(DependencyFile)).returns(Integer) }
74
162
  def self.npm_version_numeric(lockfile)
75
163
  detected_npm_version = detect_npm_version(lockfile)
@@ -141,6 +229,62 @@ module Dependabot
141
229
  PNPM_FALLBACK_VERSION
142
230
  end
143
231
 
232
+ # The concrete pnpm version that will run for this update — resolved via
233
+ # Corepack when the corepack experiment is enabled (honouring the repo's
234
+ # `packageManager` pin), otherwise the pnpm on PATH. Returns nil when the
235
+ # version can't be determined. Used to gate version-specific config such as
236
+ # `minimumReleaseAge` (added in pnpm 10.16) and `minimumReleaseAgeStrict`
237
+ # (added in pnpm 11.0), which older pnpm versions silently ignore.
238
+ sig { returns(T.nilable(Dependabot::Version)) }
239
+ def self.pnpm_version
240
+ raw = if Dependabot::Experiments.enabled?(:enable_corepack_for_npm_and_yarn)
241
+ package_manager_version(PNPMPackageManager::NAME, env: merge_corepack_env(nil))
242
+ else
243
+ local_package_manager_version(PNPMPackageManager::NAME)
244
+ end
245
+ Version.new(raw)
246
+ rescue StandardError => e
247
+ Dependabot.logger.warn("Could not determine pnpm version to gate release-age settings: #{e.message}")
248
+ nil
249
+ end
250
+
251
+ # The concrete npm version that will run — via Corepack when the corepack
252
+ # experiment is enabled (honouring the repo's `packageManager` pin, which can
253
+ # select npm 7-11), otherwise the npm on PATH. Returns nil when it can't be
254
+ # determined. Used to gate `--min-release-age`, added in npm 11.10.
255
+ sig { returns(T.nilable(Dependabot::Version)) }
256
+ def self.npm_version
257
+ raw = if Dependabot::Experiments.enabled?(:enable_corepack_for_npm_and_yarn)
258
+ package_manager_version(NpmPackageManager::NAME, env: merge_corepack_env(nil))
259
+ else
260
+ local_package_manager_version(NpmPackageManager::NAME)
261
+ end
262
+ Version.new(raw)
263
+ rescue StandardError => e
264
+ Dependabot.logger.warn("Could not determine npm version to gate release-age settings: #{e.message}")
265
+ nil
266
+ end
267
+
268
+ # True when the running npm supports `--min-release-age` (npm 11.10+). Because
269
+ # npm runs through Corepack, a repo pinned to an older npm via `packageManager`
270
+ # would reject the flag, so the cooldown gate must be skipped for it.
271
+ sig { returns(T::Boolean) }
272
+ def self.npm_supports_min_release_age?
273
+ version = npm_version
274
+ return false if version.nil?
275
+
276
+ supported = version >= Version.new(NPM_MINIMUM_RELEASE_AGE_VERSION)
277
+ if supported
278
+ Dependabot.logger.info("npm #{version} supports --min-release-age.")
279
+ else
280
+ Dependabot.logger.info(
281
+ "npm #{version} does not support --min-release-age (requires 11.10.0+); the release-age " \
282
+ "cooldown gate will not be applied to transitive dependencies."
283
+ )
284
+ end
285
+ supported
286
+ end
287
+
144
288
  sig { params(key: String, default_value: String).returns(T.untyped) }
145
289
  def self.fetch_yarnrc_yml_value(key, default_value)
146
290
  if File.exist?(".yarnrc.yml") && (yarnrc = YAML.load_file(".yarnrc.yml"))
@@ -254,10 +398,7 @@ module Dependabot
254
398
  version = Version.new(run_single_yarn_command("--version"))
255
399
  supported = version >= Version.new("4.10.0")
256
400
  if supported
257
- Dependabot.logger.info(
258
- "Yarn #{version} supports npmMinimalAgeGate. " \
259
- "Setting YARN_NPM_MINIMAL_AGE_GATE=0 to bypass the release-age gate for security updates."
260
- )
401
+ Dependabot.logger.info("Yarn #{version} supports npmMinimalAgeGate.")
261
402
  else
262
403
  Dependabot.logger.info(
263
404
  "Yarn #{version} does not support npmMinimalAgeGate (requires 4.10.0+). " \
@@ -268,8 +409,9 @@ module Dependabot
268
409
  rescue StandardError => e
269
410
  Dependabot.logger.warn(
270
411
  "Could not determine Yarn version to check npmMinimalAgeGate support: #{e.message}. " \
271
- "Assuming unsupported (returning false). YARN_NPM_MINIMAL_AGE_GATE will not be set, " \
272
- "so the registry's release-age gate may still block security updates."
412
+ "Assuming unsupported (returning false). YARN_NPM_MINIMAL_AGE_GATE will not be set, so the " \
413
+ "security-update `=0` bypass cannot be applied — any release-age gate configured in " \
414
+ ".yarnrc.yml (npmMinimalAgeGate) or enforced by the registry may still block security updates."
273
415
  )
274
416
  false
275
417
  end
@@ -301,11 +443,11 @@ module Dependabot
301
443
  # set to false. Yarn commands should _not_ be ran outside of this helper
302
444
  # to ensure that postinstall scripts are never executed, as they could
303
445
  # contain malicious code.
304
- sig { params(commands: T::Array[String]).void }
305
- def self.run_yarn_commands(*commands)
446
+ sig { params(commands: T::Array[String], env: T.nilable(T::Hash[String, String])).void }
447
+ def self.run_yarn_commands(*commands, env: nil)
306
448
  setup_yarn_berry
307
449
  commands.each do |cmd, fingerprint|
308
- run_single_yarn_command(cmd, fingerprint: fingerprint) if cmd
450
+ run_single_yarn_command(cmd, fingerprint: fingerprint, env: env) if cmd
309
451
  end
310
452
  end
311
453
 
@@ -24,8 +24,10 @@ module Dependabot
24
24
  File.join(__dir__, "../../../helpers")
25
25
  end
26
26
 
27
- sig { params(dependency_names: T::Array[String], security_updates_only: T::Boolean).returns(String) }
28
- def self.run_npm8_subdependency_update_command(dependency_names, security_updates_only: false)
27
+ sig do
28
+ params(dependency_names: T::Array[String], min_release_age_arg: T.nilable(String)).returns(String)
29
+ end
30
+ def self.run_npm8_subdependency_update_command(dependency_names, min_release_age_arg: nil)
29
31
  # NOTE: npm options
30
32
  # - `--force` ignores checks for platform (os, cpu) and engines
31
33
  # - `--ignore-scripts` disables prepare and prepack scripts which are run
@@ -37,9 +39,10 @@ module Dependabot
37
39
  "--ignore-scripts",
38
40
  "--package-lock-only"
39
41
  ]
40
- # Override any min-release-age set in .npmrc: security fixes must not be
41
- # blocked by a release-age gate the user configured for regular updates.
42
- command_args << "--min-release-age=0" if security_updates_only
42
+ # Apply the effective release-age gate: `=0` bypasses any `.npmrc` gate for
43
+ # security fixes, a positive value enforces the dependabot.yml cooldown
44
+ # floor on transitive updates. nil leaves npm's own resolution untouched.
45
+ command_args << min_release_age_arg if min_release_age_arg
43
46
  command = command_args.join(" ")
44
47
 
45
48
  fingerprint_args = [
@@ -49,41 +52,51 @@ module Dependabot
49
52
  "--ignore-scripts",
50
53
  "--package-lock-only"
51
54
  ]
52
- fingerprint_args << "--min-release-age=0" if security_updates_only
55
+ fingerprint_args << fingerprint_min_release_age_arg(min_release_age_arg) if min_release_age_arg
53
56
  fingerprint = fingerprint_args.join(" ")
54
57
 
55
58
  Helpers.run_npm_command(command, fingerprint: fingerprint)
56
59
  end
57
60
 
58
- sig { params(security_updates_only: T::Boolean).returns(String) }
59
- def self.run_npm_audit_fix_command(security_updates_only: false)
61
+ sig { params(min_release_age_arg: T.nilable(String)).returns(String) }
62
+ def self.run_npm_audit_fix_command(min_release_age_arg: nil)
60
63
  # Fallback for transitive dependencies in workspace repos where
61
64
  # `npm update` is a no-op because the package isn't in package.json.
62
65
  # `npm audit fix` updates all fixable vulnerabilities in the lockfile.
63
66
  # `--force` ignores checks for platform (os, cpu) and engines,
64
67
  # matching the flags used by run_npm8_subdependency_update_command.
65
68
  command = "audit fix --force --package-lock-only --ignore-scripts"
66
- # Override any min-release-age set in .npmrc: security fixes must not be
67
- # blocked by a release-age gate the user configured for regular updates.
68
- command += " --min-release-age=0" if security_updates_only
69
- fingerprint = command
69
+ # Apply the effective release-age gate (see run_npm8_subdependency_update_command).
70
+ command += " #{min_release_age_arg}" if min_release_age_arg
71
+ fingerprint = "audit fix --force --package-lock-only --ignore-scripts"
72
+ fingerprint += " #{fingerprint_min_release_age_arg(min_release_age_arg)}" if min_release_age_arg
70
73
 
71
74
  Helpers.run_npm_command(command, fingerprint: fingerprint)
72
75
  end
73
76
 
74
- sig { returns(String) }
75
- def self.run_pnpm_audit_fix_command
77
+ # Masks the varying cooldown day count out of the telemetry fingerprint while
78
+ # keeping the security `=0` bypass distinguishable (mirrors the npm lockfile
79
+ # updater's `fingerprint_min_release_age_arg`).
80
+ sig { params(arg: String).returns(String) }
81
+ def self.fingerprint_min_release_age_arg(arg)
82
+ arg == "--min-release-age=0" ? arg : "--min-release-age=<days>"
83
+ end
84
+
85
+ sig { returns([String, String]) }
86
+ def self.pnpm_audit_fix_command
76
87
  # Fallback for transitive dependencies where `pnpm update` is a no-op.
77
88
  # pnpm 11's update fix method updates vulnerable packages in the lockfile.
78
89
  # Older supported versions only accept `--fix`, which may add manifest overrides.
79
90
  version_output = Helpers.run_pnpm_command("-v", fingerprint: "-v")
80
91
  fix_option = pnpm_major_version(version_output) >= 11 ? "--fix=update" : "--fix"
81
92
  command = "audit #{fix_option}"
93
+ [command, command]
94
+ end
82
95
 
83
- Helpers.run_pnpm_command(
84
- command,
85
- fingerprint: command
86
- )
96
+ sig { returns(String) }
97
+ def self.run_pnpm_audit_fix_command
98
+ command, fingerprint = pnpm_audit_fix_command
99
+ Helpers.run_pnpm_command(command, fingerprint: fingerprint)
87
100
  end
88
101
 
89
102
  sig { params(output: String).returns(Integer) }
@@ -97,27 +110,36 @@ module Dependabot
97
110
  end
98
111
  private_class_method :pnpm_major_version
99
112
 
100
- sig { params(dependency_name: String, recursive: T::Boolean).returns(String) }
101
- def self.run_pnpm_deep_update_command(dependency_name, recursive: false)
113
+ sig { params(dependency_name: String, recursive: T::Boolean).returns([String, String]) }
114
+ def self.pnpm_deep_update_command(dependency_name, recursive: false)
102
115
  # `pnpm update --depth Infinity <dep>` traverses the full dependency
103
116
  # graph, allowing transitive dependencies to be updated in the lockfile
104
117
  # without relying on audit fixes that may modify manifests on older pnpm versions.
105
118
  # `-r --include-workspace-root` is required for workspace repos so the
106
119
  # update is applied across all packages.
107
120
  flags = recursive ? "-r --include-workspace-root " : ""
108
- Helpers.run_pnpm_command(
109
- "#{flags}update #{dependency_name} --depth Infinity --lockfile-only",
110
- fingerprint: "#{flags}update <dependency_name> --depth Infinity --lockfile-only"
111
- )
121
+ cmd = "#{flags}update #{dependency_name} --depth Infinity --lockfile-only"
122
+ fingerprint = "#{flags}update <dependency_name> --depth Infinity --lockfile-only"
123
+ [cmd, fingerprint]
112
124
  end
113
125
 
114
- sig { returns(String) }
115
- def self.run_yarn_audit_fix_command
126
+ sig { params(dependency_name: String, recursive: T::Boolean).returns(String) }
127
+ def self.run_pnpm_deep_update_command(dependency_name, recursive: false)
128
+ cmd, fingerprint = pnpm_deep_update_command(dependency_name, recursive: recursive)
129
+ Helpers.run_pnpm_command(cmd, fingerprint: fingerprint)
130
+ end
131
+
132
+ sig { params(env: T.nilable(T::Hash[String, String])).returns(String) }
133
+ def self.run_yarn_audit_fix_command(env: nil)
116
134
  # Fallback for transitive dependencies where `yarn up -R` is a no-op.
117
- # `yarn npm audit --fix` updates vulnerable deps in the lockfile.
135
+ # `yarn npm audit --fix` updates vulnerable deps in the lockfile. The
136
+ # release-age gate env is threaded through so this lockfile-resolving
137
+ # command honours the same cooldown (and security `=0` bypass) as the
138
+ # primary add/dedupe/remove commands.
118
139
  Helpers.run_yarn_command(
119
140
  "npm audit --fix --mode update-lockfile",
120
- fingerprint: "npm audit --fix --mode update-lockfile"
141
+ fingerprint: "npm audit --fix --mode update-lockfile",
142
+ env: env
121
143
  )
122
144
  end
123
145
  end
@@ -329,13 +329,15 @@ module Dependabot
329
329
 
330
330
  NativeHelpers.run_npm8_subdependency_update_command(
331
331
  [dependency.name],
332
- security_updates_only: security_updates_only?
332
+ min_release_age_arg: security_updates_only? ? "--min-release-age=0" : nil
333
333
  )
334
334
 
335
335
  updated_content = File.read(lockfile_name)
336
336
  if updated_content == original_content && Dependabot::Experiments.enabled?(:enable_audit_fix_fallback)
337
337
  begin
338
- NativeHelpers.run_npm_audit_fix_command(security_updates_only: security_updates_only?)
338
+ NativeHelpers.run_npm_audit_fix_command(
339
+ min_release_age_arg: security_updates_only? ? "--min-release-age=0" : nil
340
+ )
339
341
  dependency.metadata[:audit_fix_used] = true
340
342
  rescue SharedHelpers::HelperSubprocessFailed
341
343
  Dependabot.logger.info("npm audit fix failed or partially fixed — continuing with any changes made")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dependabot-npm_and_yarn
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.391.0
4
+ version: 0.392.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dependabot
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.391.0
18
+ version: 0.392.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.391.0
25
+ version: 0.392.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: debug
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -388,7 +388,7 @@ licenses:
388
388
  - MIT
389
389
  metadata:
390
390
  bug_tracker_uri: https://github.com/dependabot/dependabot-core/issues
391
- changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.391.0
391
+ changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.392.0
392
392
  rdoc_options: []
393
393
  require_paths:
394
394
  - lib