From 7b05e5161b99f94ca5a09f30a88d3100451bc295 Mon Sep 17 00:00:00 2001 From: Yeikel Santana Date: Mon, 25 May 2026 21:08:45 -0400 Subject: [PATCH] Pass --min-release-age=0 for npm security updates to bypass npmrc setting When a project sets min-release-age in .npmrc, npm refuses to install package versions released more recently than the configured age window. Dependabot ignores its own cooldown for security updates, but min-release-age is enforced by npm itself at runtime, so security update PRs fail with ETARGET when the fix version is too new. Pass --min-release-age=0 to the npm install command in NpmLockfileUpdater when running a security update job, overriding the .npmrc setting only for that invocation. The security_updates_only flag is threaded from the Job through DependencyChangeBuilder and FileUpdater options into NpmLockfileUpdater. Fixes #15112 --- .../dependabot/npm_and_yarn/file_updater.rb | 3 +- .../file_updater/npm_lockfile_updater.rb | 15 ++++++- .../file_updater/npm_lockfile_updater_spec.rb | 40 +++++++++++++++++++ .../dependabot/dependency_change_builder.rb | 2 +- .../dependency_change_builder_spec.rb | 35 ++++++++++++++++ updater/spec/dependabot/updater_spec.rb | 4 +- 6 files changed, 93 insertions(+), 6 deletions(-) diff --git a/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater.rb b/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater.rb index 43a47ebdff4..5ccd91ef235 100644 --- a/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater.rb +++ b/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater.rb @@ -469,7 +469,8 @@ def npm_lockfile_updater_for(file) lockfile: file, dependencies: dependencies, dependency_files: dependency_files, - credentials: credentials + credentials: credentials, + security_updates_only: options.fetch(:security_updates_only, false) ) end diff --git a/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/npm_lockfile_updater.rb b/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/npm_lockfile_updater.rb index 3f181e4fbdc..dcf6db2451d 100644 --- a/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/npm_lockfile_updater.rb +++ b/npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/npm_lockfile_updater.rb @@ -29,15 +29,17 @@ class NpmLockfileUpdater lockfile: Dependabot::DependencyFile, dependencies: T::Array[Dependabot::Dependency], dependency_files: T::Array[Dependabot::DependencyFile], - credentials: T::Array[Credential] + credentials: T::Array[Credential], + security_updates_only: T::Boolean ) .void end - def initialize(lockfile:, dependencies:, dependency_files:, credentials:) + def initialize(lockfile:, dependencies:, dependency_files:, credentials:, security_updates_only: false) @lockfile = lockfile @dependencies = dependencies @dependency_files = dependency_files @credentials = credentials + @security_updates_only = T.let(security_updates_only, T::Boolean) end sig { returns(Dependabot::DependencyFile) } @@ -72,6 +74,11 @@ def updated_lockfile_reponse(response) sig { returns(T::Array[Credential]) } attr_reader :credentials + sig { returns(T::Boolean) } + def security_updates_only? + @security_updates_only + end + UNREACHABLE_GIT = /fatal: repository '(?.*)' not found/ FORBIDDEN_GIT = /fatal: Authentication failed for '(?.*)'/ FORBIDDEN_PACKAGE = %r{(?[^/]+) - (Forbidden|Unauthorized)} @@ -394,6 +401,9 @@ def run_npm_install_lockfile_only(install_args = [], has_optional_dependencies: ] command_args << "--save-optional" if has_optional_dependencies + # Override any min-release-age set in .npmrc: security fixes must not be + # blocked by a release-age gate the user configured for regular updates. + command_args << "--min-release-age=0" if security_updates_only? command = command_args.join(" ") @@ -406,6 +416,7 @@ def run_npm_install_lockfile_only(install_args = [], has_optional_dependencies: ] fingerprint_args << "--save-optional" if has_optional_dependencies + fingerprint_args << "--min-release-age=0" if security_updates_only? fingerprint = fingerprint_args.join(" ") diff --git a/npm_and_yarn/spec/dependabot/npm_and_yarn/file_updater/npm_lockfile_updater_spec.rb b/npm_and_yarn/spec/dependabot/npm_and_yarn/file_updater/npm_lockfile_updater_spec.rb index 74b06e7e7ee..b122beb30af 100644 --- a/npm_and_yarn/spec/dependabot/npm_and_yarn/file_updater/npm_lockfile_updater_spec.rb +++ b/npm_and_yarn/spec/dependabot/npm_and_yarn/file_updater/npm_lockfile_updater_spec.rb @@ -1360,6 +1360,46 @@ end end + describe "#run_npm_install_lockfile_only" do + let(:files) { project_dependency_files("npm8/simple") } + let(:install_args) { ["lodash@4.18.1"] } + + context "when security_updates_only is true" do + let(:updater) do + described_class.new( + lockfile: package_lock, + dependency_files: files, + dependencies: dependencies, + credentials: credentials, + security_updates_only: true + ) + end + + it "passes --min-release-age=0 to override the .npmrc setting" do + expect(Dependabot::NpmAndYarn::Helpers).to receive(:run_npm_command) do |command, _options| + expect(command).to include("--min-release-age=0") + expect(command).to include("--package-lock-only") + expect(command).to include("--force") + "" + end + + updater.send(:run_npm_install_lockfile_only, install_args) + end + end + + context "when security_updates_only is false (default)" do + it "does not pass --min-release-age=0" do + expect(Dependabot::NpmAndYarn::Helpers).to receive(:run_npm_command) do |command, _options| + expect(command).not_to include("--min-release-age=0") + expect(command).to include("--package-lock-only") + "" + end + + updater.send(:run_npm_install_lockfile_only, install_args) + end + end + end + describe "#optional_dependency?" do it "correctly identifies optional dependencies" do optional_dep = Dependabot::Dependency.new( diff --git a/updater/lib/dependabot/dependency_change_builder.rb b/updater/lib/dependabot/dependency_change_builder.rb index 59398760f1f..9771e24e110 100644 --- a/updater/lib/dependabot/dependency_change_builder.rb +++ b/updater/lib/dependabot/dependency_change_builder.rb @@ -189,7 +189,7 @@ def file_updater_for(dependencies) dependency_files: dependency_files, repo_contents_path: job.repo_contents_path, credentials: job.credentials, - options: job.experiments + options: job.experiments.merge(security_updates_only: job.security_updates_only?) ) end end diff --git a/updater/spec/dependabot/dependency_change_builder_spec.rb b/updater/spec/dependabot/dependency_change_builder_spec.rb index 52a7160f321..2eda65932a3 100644 --- a/updater/spec/dependabot/dependency_change_builder_spec.rb +++ b/updater/spec/dependabot/dependency_change_builder_spec.rb @@ -23,6 +23,7 @@ } ], experiments: {}, + security_updates_only?: false, source: source ) end @@ -125,6 +126,40 @@ def dependency_group_source Dependabot::DependencyGroup.new(name: "dummy-pkg-*", rules: { patterns: ["dummy-pkg-*"] }) end + context "when the job is a security update" do + let(:change_source) { lead_dependency_change_source } + + before do + allow(job).to receive(:security_updates_only?).and_return(true) + stub_file_updater(updated_dependency_files: dependency_files.reject(&:support_file?)) + end + + it "passes security_updates_only: true in options to the file updater" do + create_change + + expect(file_updater_class).to have_received(:new).with( + hash_including(options: hash_including(security_updates_only: true)) + ) + end + end + + context "when the job is not a security update" do + let(:change_source) { lead_dependency_change_source } + + before do + allow(job).to receive(:security_updates_only?).and_return(false) + stub_file_updater(updated_dependency_files: dependency_files.reject(&:support_file?)) + end + + it "passes security_updates_only: false in options to the file updater" do + create_change + + expect(file_updater_class).to have_received(:new).with( + hash_including(options: hash_including(security_updates_only: false)) + ) + end + end + context "when the source is a lead dependency" do let(:change_source) { lead_dependency_change_source } diff --git a/updater/spec/dependabot/updater_spec.rb b/updater/spec/dependabot/updater_spec.rb index a38318644db..ba7b1d81140 100644 --- a/updater/spec/dependabot/updater_spec.rb +++ b/updater/spec/dependabot/updater_spec.rb @@ -660,7 +660,7 @@ def expect_update_checker_with_ignored_versions(versions, dependency_matcher: an dependency_files: default_dependency_files, repo_contents_path: nil, credentials: anything, - options: { cloning: true } + options: hash_including(cloning: true) ).and_call_original expect(service).to receive(:create_pull_request).once @@ -2165,7 +2165,7 @@ def expect_update_checker_with_ignored_versions(versions, dependency_matcher: an ], repo_contents_path: nil, credentials: anything, - options: { large_hadron_collider: true } + options: hash_including(large_hadron_collider: true) ).and_call_original updater.run