From b07b1cff468c88c649b53d4fb202b4d2fbadb6e7 Mon Sep 17 00:00:00 2001 From: Yeikel Santana Date: Sun, 24 May 2026 23:33:55 -0400 Subject: [PATCH] Read npm min-release-age from .npmrc and apply as cooldown floor - `default_days` now uses `[existing.default_days, npmrc_days].max` so the npmrc value never lowers a higher value set in dependabot.yml - Conflict warning is conditional: only fires when at least one cooldown field is actually below the npmrc threshold; otherwise logs at debug - Removed `default_days` from the per-field override-warning loop; the top-level conflict message covers it; per-field messages are for the three `semver_*_days` fields only - `.npmrc` filename match tightened to `File.basename(f.name) == ".npmrc"` to avoid false matches like `foo.npmrc` - Extracted `log_npmrc_cooldown_conflicts` and `merge_cooldown_with_npmrc_floor` helpers (satisfies Rubocop Metrics cops) - TODO comment added for Yarn 4.10+ `npmMinimalAgeGate` follow-up Spec changes: - "when explicit update_cooldown already exceeds the npmrc floor": asserts default_days stays unchanged and no warning is logged - "when dependabot.yml default_days is below the npmrc floor": expects conflict warning once and per-field warnings for semver fields only - Replace `instance_variable_get(:@update_cooldown)` with `checker.update_cooldown` --- .../dependabot/npm_and_yarn/update_checker.rb | 110 ++++++++++++++++++ .../npm_and_yarn/update_checker_spec.rb | 106 +++++++++++++++++ .../npm6/npmrc_min_release_age/.npmrc | 1 + .../npmrc_min_release_age/package-lock.json | 75 ++++++++++++ .../npm6/npmrc_min_release_age/package.json | 25 ++++ 5 files changed, 317 insertions(+) create mode 100644 npm_and_yarn/spec/fixtures/projects/npm6/npmrc_min_release_age/.npmrc create mode 100644 npm_and_yarn/spec/fixtures/projects/npm6/npmrc_min_release_age/package-lock.json create mode 100644 npm_and_yarn/spec/fixtures/projects/npm6/npmrc_min_release_age/package.json diff --git a/npm_and_yarn/lib/dependabot/npm_and_yarn/update_checker.rb b/npm_and_yarn/lib/dependabot/npm_and_yarn/update_checker.rb index 272884f2c05..66eb861bd27 100644 --- a/npm_and_yarn/lib/dependabot/npm_and_yarn/update_checker.rb +++ b/npm_and_yarn/lib/dependabot/npm_and_yarn/update_checker.rb @@ -65,6 +65,7 @@ def initialize( # rubocop:disable Metrics/AbcSize @package_json = T.let(nil, T.nilable(Dependabot::DependencyFile)) @git_commit_checker = T.let(nil, T.nilable(Dependabot::GitCommitChecker)) super + apply_npmrc_min_release_age end sig { returns(T::Boolean) } @@ -572,6 +573,115 @@ def original_source(updated_dependency) sources.first end + # Reads `min-release-age` from .npmrc and applies it as a floor for every + # cooldown field. npm enforces this constraint at install time, so any version + # younger than the threshold will cause `npm install` to fail. When a + # dependabot.yml cooldown is also present, the npmrc value raises any field + # that is below it and leaves higher values unchanged. Include/exclude patterns + # are always dropped because npm applies min-release-age globally with no + # per-package filtering. + sig { void } + def apply_npmrc_min_release_age + npmrc_days = npmrc_min_release_age_days + return unless npmrc_days&.positive? + + if @update_cooldown.nil? + @update_cooldown = Dependabot::Package::ReleaseCooldownOptions.new(default_days: npmrc_days) + else + existing = @update_cooldown + log_npmrc_cooldown_conflicts(existing, npmrc_days) + @update_cooldown = merge_cooldown_with_npmrc_floor(existing, npmrc_days) + end + end + + sig do + params( + existing: Dependabot::Package::ReleaseCooldownOptions, + npmrc_days: Integer + ).void + end + def log_npmrc_cooldown_conflicts(existing, npmrc_days) + if existing.include.any? || existing.exclude.any? + Dependabot.logger.warn( + ".npmrc min-release-age does not support include/exclude patterns; " \ + "dropping dependabot.yml update_cooldown include/exclude configuration." + ) + end + + all_days = [existing.default_days, existing.semver_major_days, + existing.semver_minor_days, existing.semver_patch_days] + unless all_days.any? { |days| days < npmrc_days } + Dependabot.logger.debug( + ".npmrc min-release-age (#{npmrc_days} days) is already satisfied by all " \ + "dependabot.yml update_cooldown values; no adjustment needed." + ) + return + end + + Dependabot.logger.warn( + ".npmrc min-release-age (#{npmrc_days} days) conflicts with dependabot.yml update_cooldown " \ + "(default_days: #{existing.default_days}); it acts as a minimum floor for all cooldown values." + ) + { semver_major_days: existing.semver_major_days, + semver_minor_days: existing.semver_minor_days, + semver_patch_days: existing.semver_patch_days }.each do |field, configured_days| + next unless configured_days < npmrc_days + + Dependabot.logger.warn( + ".npmrc min-release-age (#{npmrc_days} days) overrides dependabot.yml #{field} " \ + "(#{configured_days} days) because it would cause npm install to fail." + ) + end + end + + sig do + params( + existing: Dependabot::Package::ReleaseCooldownOptions, + npmrc_days: Integer + ).returns(Dependabot::Package::ReleaseCooldownOptions) + end + def merge_cooldown_with_npmrc_floor(existing, npmrc_days) + Dependabot::Package::ReleaseCooldownOptions.new( + default_days: [existing.default_days, npmrc_days].max, + semver_major_days: [existing.semver_major_days, npmrc_days].max, + semver_minor_days: [existing.semver_minor_days, npmrc_days].max, + semver_patch_days: [existing.semver_patch_days, npmrc_days].max, + include: [], + exclude: [] + ) + end + + sig { returns(T.nilable(Integer)) } + def npmrc_min_release_age_days + npmrc_file = dependency_files.find { |f| File.basename(f.name) == ".npmrc" } + unless npmrc_file&.content + Dependabot.logger.debug("No .npmrc file found; skipping min-release-age check.") + return nil + end + + T.must(npmrc_file.content).split("\n").each do |line| + days = parse_min_release_age_line(line, npmrc_file.name) + return days if days + end + Dependabot.logger.debug("No min-release-age key found in #{npmrc_file.name}.") + nil + end + + sig { params(line: String, filename: String).returns(T.nilable(Integer)) } + def parse_min_release_age_line(line, filename) + key, value = line.strip.split("=", 2) + return nil unless key&.strip == "min-release-age" && value + + parsed = T.let(Integer(value.strip, 10, exception: false), T.nilable(Integer)) + if parsed&.positive? + Dependabot.logger.debug("Found min-release-age=#{parsed} days in #{filename}.") + parsed + else + Dependabot.logger.debug("Ignoring invalid min-release-age value '#{value.strip}' in #{filename}.") + nil + end + end + sig { returns(T.nilable(Dependabot::DependencyFile)) } def package_json @package_json ||= diff --git a/npm_and_yarn/spec/dependabot/npm_and_yarn/update_checker_spec.rb b/npm_and_yarn/spec/dependabot/npm_and_yarn/update_checker_spec.rb index 8176eb8f464..78ac2fcc8e8 100644 --- a/npm_and_yarn/spec/dependabot/npm_and_yarn/update_checker_spec.rb +++ b/npm_and_yarn/spec/dependabot/npm_and_yarn/update_checker_spec.rb @@ -2395,4 +2395,110 @@ def eq_including_metadata(expected_array) expect(updated_deps[0].name).to eq("is-stream") end end + + describe "npmrc min-release-age cooldown" do + let(:dependency_files) { project_dependency_files("npm6/npmrc_min_release_age") } + + it "creates a cooldown from the npmrc min-release-age value" do + expect(checker.update_cooldown).to be_a(Dependabot::Package::ReleaseCooldownOptions) + expect(checker.update_cooldown.default_days).to eq(3) + end + + context "when an explicit update_cooldown already exceeds the npmrc floor" do + let(:checker) do + described_class.new( + dependency: dependency, + dependency_files: dependency_files, + credentials: credentials, + update_cooldown: Dependabot::Package::ReleaseCooldownOptions.new(default_days: 10), + options: options + ) + end + + it "keeps default_days unchanged and logs no warning" do + expect(Dependabot.logger).not_to receive(:warn) + expect(checker.update_cooldown.default_days).to eq(10) + end + end + + context "when dependabot.yml default_days is below the npmrc floor" do + let(:checker) do + described_class.new( + dependency: dependency, + dependency_files: dependency_files, + credentials: credentials, + update_cooldown: Dependabot::Package::ReleaseCooldownOptions.new(default_days: 1), + options: options + ) + end + + it "raises default_days to the npmrc floor and logs semver-field warnings only" do + expect(Dependabot.logger).to receive(:warn).with( + ".npmrc min-release-age (3 days) conflicts with dependabot.yml update_cooldown " \ + "(default_days: 1); it acts as a minimum floor for all cooldown values." + ).once + # ReleaseCooldownOptions derives semver fields from default_days when not set + # explicitly, so all three are 1 and each gets an override warning. + %w(semver_major_days semver_minor_days semver_patch_days).each do |field| + expect(Dependabot.logger).to receive(:warn).with( + ".npmrc min-release-age (3 days) overrides dependabot.yml #{field} " \ + "(1 days) because it would cause npm install to fail." + ) + end + expect(checker.update_cooldown.default_days).to eq(3) + end + end + + context "when a semver-specific day is below the npmrc floor" do + let(:checker) do + described_class.new( + dependency: dependency, + dependency_files: dependency_files, + credentials: credentials, + update_cooldown: Dependabot::Package::ReleaseCooldownOptions.new( + default_days: 10, + semver_patch_days: 1 + ), + options: options + ) + end + + it "raises semver_patch_days to the npmrc floor and logs an override warning" do + expect(Dependabot.logger).to receive(:warn).with( + ".npmrc min-release-age (3 days) conflicts with dependabot.yml update_cooldown " \ + "(default_days: 10); it acts as a minimum floor for all cooldown values." + ) + expect(Dependabot.logger).to receive(:warn).with( + ".npmrc min-release-age (3 days) overrides dependabot.yml semver_patch_days " \ + "(1 days) because it would cause npm install to fail." + ) + expect(checker.update_cooldown.semver_patch_days).to eq(3) + end + end + + context "when include/exclude patterns are configured alongside npmrc" do + let(:checker) do + described_class.new( + dependency: dependency, + dependency_files: dependency_files, + credentials: credentials, + update_cooldown: Dependabot::Package::ReleaseCooldownOptions.new( + default_days: 5, + include: %w(lodash react) + ), + options: options + ) + end + + it "drops include/exclude and logs a warning" do + expect(Dependabot.logger).to receive(:warn).with( + ".npmrc min-release-age does not support include/exclude patterns; " \ + "dropping dependabot.yml update_cooldown include/exclude configuration." + ) + expect(checker.update_cooldown.include).to be_empty + expect(checker.update_cooldown.exclude).to be_empty + expect(checker.update_cooldown.default_days).to eq(5) + end + end + end end diff --git a/npm_and_yarn/spec/fixtures/projects/npm6/npmrc_min_release_age/.npmrc b/npm_and_yarn/spec/fixtures/projects/npm6/npmrc_min_release_age/.npmrc new file mode 100644 index 00000000000..ec9e05d8a7b --- /dev/null +++ b/npm_and_yarn/spec/fixtures/projects/npm6/npmrc_min_release_age/.npmrc @@ -0,0 +1 @@ +min-release-age=3 diff --git a/npm_and_yarn/spec/fixtures/projects/npm6/npmrc_min_release_age/package-lock.json b/npm_and_yarn/spec/fixtures/projects/npm6/npmrc_min_release_age/package-lock.json new file mode 100644 index 00000000000..bca00e73d3a --- /dev/null +++ b/npm_and_yarn/spec/fixtures/projects/npm6/npmrc_min_release_age/package-lock.json @@ -0,0 +1,75 @@ +{ + "name": "{{ name }}", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "encoding": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz", + "integrity": "sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=", + "requires": { + "iconv-lite": "0.4.19" + } + }, + "es6-promise": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", + "integrity": "sha1-oIzd6EzNvzTQJ6FFG8kdS80ophM=" + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", + "dev": true + }, + "fetch-factory": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/fetch-factory/-/fetch-factory-0.0.1.tgz", + "integrity": "sha1-4AdgWb2zHjFHx1s7jAQTO6jH4HE=", + "requires": { + "es6-promise": "3.3.1", + "isomorphic-fetch": "2.2.1", + "lodash": "3.10.1" + } + }, + "iconv-lite": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz", + "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==" + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" + }, + "isomorphic-fetch": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz", + "integrity": "sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk=", + "requires": { + "node-fetch": "1.7.3", + "whatwg-fetch": "2.0.3" + } + }, + "lodash": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", + "integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=" + }, + "node-fetch": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz", + "integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==", + "requires": { + "encoding": "0.1.12", + "is-stream": "1.1.0" + } + }, + "whatwg-fetch": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz", + "integrity": "sha1-nITsLc9oGH/wC8ZOEnS0QhduHIQ=" + } + } +} diff --git a/npm_and_yarn/spec/fixtures/projects/npm6/npmrc_min_release_age/package.json b/npm_and_yarn/spec/fixtures/projects/npm6/npmrc_min_release_age/package.json new file mode 100644 index 00000000000..d9affc296f1 --- /dev/null +++ b/npm_and_yarn/spec/fixtures/projects/npm6/npmrc_min_release_age/package.json @@ -0,0 +1,25 @@ +{ + "name": "{{ name }}", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no\\ test\\ specified\" && exit 1", + "prettify": "prettier --write \"{{packages/*/src,examples,cypress,scripts}/**/,}*.{js,jsx,ts,tsx,css,md}\"" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/waltfy/PROTO_TEST.git" + }, + "author": "", + "license": "ISC", + "bugs": { + "url": "https://github.com/waltfy/PROTO_TEST/issues" + }, + "homepage": "https://github.com/waltfy/PROTO_TEST#readme", + "dependencies": { + "fetch-factory": "^0.0.1" + }, + "devDependencies": { + "etag" : "^1.0.0" + }}