Skip to content

Commit 7b05e51

Browse files
committed
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
1 parent 5f43948 commit 7b05e51

6 files changed

Lines changed: 93 additions & 6 deletions

File tree

npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,8 @@ def npm_lockfile_updater_for(file)
469469
lockfile: file,
470470
dependencies: dependencies,
471471
dependency_files: dependency_files,
472-
credentials: credentials
472+
credentials: credentials,
473+
security_updates_only: options.fetch(:security_updates_only, false)
473474
)
474475
end
475476

npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater/npm_lockfile_updater.rb

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,17 @@ class NpmLockfileUpdater
2929
lockfile: Dependabot::DependencyFile,
3030
dependencies: T::Array[Dependabot::Dependency],
3131
dependency_files: T::Array[Dependabot::DependencyFile],
32-
credentials: T::Array[Credential]
32+
credentials: T::Array[Credential],
33+
security_updates_only: T::Boolean
3334
)
3435
.void
3536
end
36-
def initialize(lockfile:, dependencies:, dependency_files:, credentials:)
37+
def initialize(lockfile:, dependencies:, dependency_files:, credentials:, security_updates_only: false)
3738
@lockfile = lockfile
3839
@dependencies = dependencies
3940
@dependency_files = dependency_files
4041
@credentials = credentials
42+
@security_updates_only = T.let(security_updates_only, T::Boolean)
4143
end
4244

4345
sig { returns(Dependabot::DependencyFile) }
@@ -72,6 +74,11 @@ def updated_lockfile_reponse(response)
7274
sig { returns(T::Array[Credential]) }
7375
attr_reader :credentials
7476

77+
sig { returns(T::Boolean) }
78+
def security_updates_only?
79+
@security_updates_only
80+
end
81+
7582
UNREACHABLE_GIT = /fatal: repository '(?<url>.*)' not found/
7683
FORBIDDEN_GIT = /fatal: Authentication failed for '(?<url>.*)'/
7784
FORBIDDEN_PACKAGE = %r{(?<package_req>[^/]+) - (Forbidden|Unauthorized)}
@@ -394,6 +401,9 @@ def run_npm_install_lockfile_only(install_args = [], has_optional_dependencies:
394401
]
395402

396403
command_args << "--save-optional" if has_optional_dependencies
404+
# Override any min-release-age set in .npmrc: security fixes must not be
405+
# blocked by a release-age gate the user configured for regular updates.
406+
command_args << "--min-release-age=0" if security_updates_only?
397407

398408
command = command_args.join(" ")
399409

@@ -406,6 +416,7 @@ def run_npm_install_lockfile_only(install_args = [], has_optional_dependencies:
406416
]
407417

408418
fingerprint_args << "--save-optional" if has_optional_dependencies
419+
fingerprint_args << "--min-release-age=0" if security_updates_only?
409420

410421
fingerprint = fingerprint_args.join(" ")
411422

npm_and_yarn/spec/dependabot/npm_and_yarn/file_updater/npm_lockfile_updater_spec.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1360,6 +1360,46 @@
13601360
end
13611361
end
13621362

1363+
describe "#run_npm_install_lockfile_only" do
1364+
let(:files) { project_dependency_files("npm8/simple") }
1365+
let(:install_args) { ["lodash@4.18.1"] }
1366+
1367+
context "when security_updates_only is true" do
1368+
let(:updater) do
1369+
described_class.new(
1370+
lockfile: package_lock,
1371+
dependency_files: files,
1372+
dependencies: dependencies,
1373+
credentials: credentials,
1374+
security_updates_only: true
1375+
)
1376+
end
1377+
1378+
it "passes --min-release-age=0 to override the .npmrc setting" do
1379+
expect(Dependabot::NpmAndYarn::Helpers).to receive(:run_npm_command) do |command, _options|
1380+
expect(command).to include("--min-release-age=0")
1381+
expect(command).to include("--package-lock-only")
1382+
expect(command).to include("--force")
1383+
""
1384+
end
1385+
1386+
updater.send(:run_npm_install_lockfile_only, install_args)
1387+
end
1388+
end
1389+
1390+
context "when security_updates_only is false (default)" do
1391+
it "does not pass --min-release-age=0" do
1392+
expect(Dependabot::NpmAndYarn::Helpers).to receive(:run_npm_command) do |command, _options|
1393+
expect(command).not_to include("--min-release-age=0")
1394+
expect(command).to include("--package-lock-only")
1395+
""
1396+
end
1397+
1398+
updater.send(:run_npm_install_lockfile_only, install_args)
1399+
end
1400+
end
1401+
end
1402+
13631403
describe "#optional_dependency?" do
13641404
it "correctly identifies optional dependencies" do
13651405
optional_dep = Dependabot::Dependency.new(

updater/lib/dependabot/dependency_change_builder.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ def file_updater_for(dependencies)
189189
dependency_files: dependency_files,
190190
repo_contents_path: job.repo_contents_path,
191191
credentials: job.credentials,
192-
options: job.experiments
192+
options: job.experiments.merge(security_updates_only: job.security_updates_only?)
193193
)
194194
end
195195
end

updater/spec/dependabot/dependency_change_builder_spec.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
}
2424
],
2525
experiments: {},
26+
security_updates_only?: false,
2627
source: source
2728
)
2829
end
@@ -125,6 +126,40 @@ def dependency_group_source
125126
Dependabot::DependencyGroup.new(name: "dummy-pkg-*", rules: { patterns: ["dummy-pkg-*"] })
126127
end
127128

129+
context "when the job is a security update" do
130+
let(:change_source) { lead_dependency_change_source }
131+
132+
before do
133+
allow(job).to receive(:security_updates_only?).and_return(true)
134+
stub_file_updater(updated_dependency_files: dependency_files.reject(&:support_file?))
135+
end
136+
137+
it "passes security_updates_only: true in options to the file updater" do
138+
create_change
139+
140+
expect(file_updater_class).to have_received(:new).with(
141+
hash_including(options: hash_including(security_updates_only: true))
142+
)
143+
end
144+
end
145+
146+
context "when the job is not a security update" do
147+
let(:change_source) { lead_dependency_change_source }
148+
149+
before do
150+
allow(job).to receive(:security_updates_only?).and_return(false)
151+
stub_file_updater(updated_dependency_files: dependency_files.reject(&:support_file?))
152+
end
153+
154+
it "passes security_updates_only: false in options to the file updater" do
155+
create_change
156+
157+
expect(file_updater_class).to have_received(:new).with(
158+
hash_including(options: hash_including(security_updates_only: false))
159+
)
160+
end
161+
end
162+
128163
context "when the source is a lead dependency" do
129164
let(:change_source) { lead_dependency_change_source }
130165

updater/spec/dependabot/updater_spec.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -660,7 +660,7 @@ def expect_update_checker_with_ignored_versions(versions, dependency_matcher: an
660660
dependency_files: default_dependency_files,
661661
repo_contents_path: nil,
662662
credentials: anything,
663-
options: { cloning: true }
663+
options: hash_including(cloning: true)
664664
).and_call_original
665665

666666
expect(service).to receive(:create_pull_request).once
@@ -2165,7 +2165,7 @@ def expect_update_checker_with_ignored_versions(versions, dependency_matcher: an
21652165
],
21662166
repo_contents_path: nil,
21672167
credentials: anything,
2168-
options: { large_hadron_collider: true }
2168+
options: hash_including(large_hadron_collider: true)
21692169
).and_call_original
21702170

21712171
updater.run

0 commit comments

Comments
 (0)