Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .github/workflows/smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ jobs:
- { path: npm_and_yarn, name: yarn-berry, ecosystem: npm }
- { path: npm_and_yarn, name: yarn-berry-workspaces, ecosystem: npm }
- { path: nuget, name: nuget, ecosystem: nuget }
- { path: nuget, name: nuget-lockfiles, ecosystem: nuget }
- { path: pub, name: pub, ecosystem: pub }
- { path: python, name: pip, ecosystem: pip }
- { path: python, name: pipenv, ecosystem: pip }
Expand Down Expand Up @@ -196,6 +197,13 @@ jobs:
- 'common/**'
- 'updater/**'
- 'nuget/**'
nuget-lockfiles:
- .github/workflows/smoke.yml
- .dockerignore
- Dockerfile.updater-core
- 'common/**'
- 'updater/**'
- 'nuget/**'
pip:
- .github/workflows/smoke.yml
- .dockerignore
Expand Down Expand Up @@ -287,15 +295,15 @@ jobs:
gh release download --repo dependabot/cli -p "*linux-amd64.tar.gz"
tar xzvf *.tar.gz >/dev/null 2>&1
./dependabot --version
URL=https://api.github.com/repos/dependabot/smoke-tests/contents/tests/smoke-${{ matrix.suite.name }}.yaml
URL=https://api.github.com/repos/anthony-c-martin/smoke-tests/contents/tests/smoke-${{ matrix.suite.name }}.yaml
curl $(gh api $URL --jq .download_url) -o smoke.yaml

# Download the Proxy cache. The job is ideally 100% cached so no real calls are made.
# Allowed to fail to get out of checking and egg situations, for example, when adding a new ecosystem.
- name: Download cache
if: steps.changes.outputs[matrix.suite.name] == 'true'
run: |
gh run download --repo dependabot/smoke-tests --name cache-${{ matrix.suite.name }} --dir cache
gh run download --repo anthony-c-martin/smoke-tests --name cache-${{ matrix.suite.name }} --dir cache
continue-on-error: true

- name: Build ecosystem image
Expand Down
18 changes: 17 additions & 1 deletion nuget/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
FROM ghcr.io/dependabot/dependabot-updater-core

USER root

# Set up the install directory
ENV DOTNET_HOME=/opt/dotnet \
PATH="${PATH}:/opt/dotnet"
RUN mkdir -p "$DOTNET_HOME" && chown dependabot:dependabot "$DOTNET_HOME"

USER dependabot

ENV DOTNET_NOLOGO=true \
DOTNET_CLI_TELEMETRY_OPTOUT=true

# Run dotnet without globalization support
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1

# Fetch and install dotnet
RUN curl -sSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --channel 7.0 --install-dir /opt/dotnet

COPY --chown=dependabot:dependabot nuget $DEPENDABOT_HOME/nuget
COPY --chown=dependabot:dependabot common $DEPENDABOT_HOME/common
COPY --chown=dependabot:dependabot updater $DEPENDABOT_HOME/dependabot-updater
COPY --chown=dependabot:dependabot updater $DEPENDABOT_HOME/dependabot-updater
21 changes: 17 additions & 4 deletions nuget/lib/dependabot/nuget/file_fetcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def fetch_files

fetched_files += packages_config_files
fetched_files += nuget_config_files
fetched_files += packages_lock_files
fetched_files << global_json if global_json
fetched_files << dotnet_tools_json if dotnet_tools_json
fetched_files << packages_props if packages_props
Expand Down Expand Up @@ -222,22 +223,34 @@ def nuget_config_files
candidate_paths = [*project_files.map { |f| File.dirname(f.name) }, "."].uniq
visited_directories = Set.new
candidate_paths.each do |dir|
search_in_directory_and_parents(dir, visited_directories)
search_in_directory_and_parents(dir, visited_directories, @nuget_config_files, "nuget.config")
end
@nuget_config_files
end

def search_in_directory_and_parents(dir, visited_directories)
def packages_lock_files
return @packages_lock_files if @packages_lock_files

@packages_lock_files = []
candidate_paths = [*project_files.map { |f| File.dirname(f.name) }, "."].uniq
visited_directories = Set.new
candidate_paths.each do |dir|
search_in_directory_and_parents(dir, visited_directories, @packages_lock_files, "packages.lock.json")
end
@packages_lock_files
end

def search_in_directory_and_parents(dir, visited_directories, files_list, file_name)
loop do
break if visited_directories.include?(dir)

visited_directories << dir
file = repo_contents(dir: dir)
.find { |f| f.name.casecmp("nuget.config").zero? }
.find { |f| f.name.casecmp(file_name).zero? }
if file
file = fetch_file_from_host(File.join(dir, file.name))
file&.tap { |f| f.support_file = true }
@nuget_config_files << file
files_list << file
end
dir = File.dirname(dir)
end
Expand Down
5 changes: 5 additions & 0 deletions nuget/lib/dependabot/nuget/file_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ def project_import_files
project_files -
packages_config_files -
nuget_configs -
package_locks -
[global_json] -
[dotnet_tools_json]
end
Expand All @@ -99,6 +100,10 @@ def nuget_configs
dependency_files.select { |f| f.name.match?(/nuget\.config$/i) }
end

def package_locks
dependency_files.select { |f| f.name.match?(/packages\.lock\.json$/i) }
end

def global_json
dependency_files.find { |f| f.name.casecmp("global.json").zero? }
end
Expand Down
46 changes: 43 additions & 3 deletions nuget/lib/dependabot/nuget/file_updater.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class FileUpdater < Dependabot::FileUpdaters::Base
require_relative "file_updater/packages_config_declaration_finder"
require_relative "file_updater/project_file_declaration_finder"
require_relative "file_updater/property_value_updater"
require_relative "file_updater/lockfile_updater"

def self.updated_files_regex
[
Expand All @@ -19,7 +20,8 @@ def self.updated_files_regex
/^dotnet-tools\.json$/i,
/^Directory\.Build\.props$/i,
/^Directory\.Build\.targets$/i,
/^Packages\.props$/i
/^Packages\.props$/i,
/^packages\.lock\.json$/i
]
end

Expand All @@ -28,7 +30,7 @@ def updated_dependency_files

# Loop through each of the changed requirements, applying changes to
# all files for that change. Note that the logic is different here
# to other languages because donet has property inheritance across
# to other languages because dotnet has property inheritance across
# files
dependencies.each do |dependency|
updated_files = update_files_for_dependency(
Expand All @@ -37,6 +39,8 @@ def updated_dependency_files
)
end

updated_files = update_lock_files(updated_files)

updated_files.reject! { |f| dependency_files.include?(f) }

raise "No files changed!" if updated_files.none?
Expand All @@ -47,7 +51,15 @@ def updated_dependency_files
private

def project_files
dependency_files.select { |df| df.name.match?(/\.[a-z]{2}proj$|[Dd]irectory.[Pp]ackages.props/) }
dependency_files.select { |df| project_file?(df) }
end

def project_file?(file)
File.basename(file.name).match?(/\.[a-z]{2}proj$|[Dd]irectory.[Pp]ackages.props/)
end

def lock_file?(file)
File.basename(file.name).match?(/^packages\.lock\.json$/i)
end

def packages_config_files
Expand Down Expand Up @@ -175,6 +187,34 @@ def updated_declaration(old_declaration, previous_req, requirement)
requirement.fetch(:requirement)
)
end

def update_lock_files(files)
files = files.dup

lock_files = files.select { |f| lock_file?(f) }
lock_files.each do |lock_file|
project_file = files.find { |f| project_file?(f) && File.dirname(f.name) == File.dirname(lock_file.name) }
next if project_file.nil?
Comment on lines +196 to +197
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain this check?

It seems like there can be more than one lockfile (packages.lock.json) per project?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general there's a lockfile for every project in a solution, and transitive dependencies are represented in each lockfile.

So for example, you could have a structure like the following:

  • dirs.sln (solution file, contains refs to projA & projB)
  • projA/projA.csproj (proj file, has nuget reference to nugetX)
  • projB/projB.csproj (proj file, has project reference to projA)

If dependabot runs for the solution file (dirs.sln), and decides to bump nugetX, you would expect to see:

  • projA/packages.lock.json has a direct dependency, and is updated
  • projB/packages.lock.json has a transitive dependency, and is also updated

Here's a concrete example of what this looks like in my project:


new_content = updated_lockfile_content(files, lock_file)
next if new_content == lock_file.content

files[files.index(lock_file)] =
updated_file(file: lock_file, content: new_content)
end

files
end

def updated_lockfile_content(files, lock_file)
@updated_lockfile_content ||= {}
@updated_lockfile_content[lock_file.name] ||=
LockfileUpdater.new(
dependency_files: files,
lock_file: lock_file,
credentials: credentials
).updated_lockfile_content
end
end
end
end
Expand Down
69 changes: 69 additions & 0 deletions nuget/lib/dependabot/nuget/file_updater/lockfile_updater.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# frozen_string_literal: true

require "dependabot/nuget/file_updater"

module Dependabot
module Nuget
class FileUpdater
class LockfileUpdater
def initialize(dependency_files:, lock_file:, credentials:)
@dependency_files = dependency_files
@lock_file = lock_file
@credentials = credentials
end

def updated_lockfile_content
@updated_lockfile_content ||=
begin
build_updated_lockfile
end
end

private

attr_reader :dependency_files, :lock_file, :credentials

def build_updated_lockfile
SharedHelpers.in_a_temporary_directory do
SharedHelpers.with_git_configured(credentials: credentials) do
dependency_files.each do |file|
path = file.name
FileUtils.mkdir_p(Pathname.new(path).dirname)
File.write(path, file.content)
end

Dir.chdir(lock_file_directory) do
run_dotnet_restore
end
end
end
rescue SharedHelpers::HelperSubprocessFailed => e
handle_dotnet_restore_error(e)
end

def run_dotnet_restore
command = [
"dotnet",
"restore",
"--force-evaluate"
].join(" ")
SharedHelpers.run_shell_command(command)

File.read(lock_file_basename)
end

def handle_dotnet_restore_error(error)
raise error
end

def lock_file_directory
Pathname.new(lock_file.name).dirname.to_s
end

def lock_file_basename
Pathname.new(lock_file.name).basename.to_s
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# frozen_string_literal: true

require "json"
require "spec_helper"
require "dependabot/dependency"
require "dependabot/dependency_file"
require "dependabot/nuget/file_updater/lockfile_updater"

RSpec.describe Dependabot::Nuget::FileUpdater::LockfileUpdater do
let(:updater) do
described_class.new(
dependency_files: dependency_files,
lock_file: lockfile,
credentials: [{
"type" => "git_source",
"host" => "github.com"
}]
)
end

let(:dependency_files) { [csproj, lockfile] }
let(:csproj) do
Dependabot::DependencyFile.new(name: "myproj.csproj", content: csproj_body)
end
let(:lockfile) do
Dependabot::DependencyFile.new(name: "packages.lock.json", content: lockfile_body)
end
let(:csproj_body) { fixture("csproj", csproj_fixture_name) }
let(:lockfile_body) { fixture("lockfiles", lockfile_fixture_name) }
let(:csproj_fixture_name) { "lockfiles_basic" }
let(:lockfile_fixture_name) { "lockfiles_basic" }
let(:tmp_path) { Dependabot::Utils::BUMP_TMP_DIR_PATH }

before { FileUtils.mkdir_p(tmp_path) }

describe "#updated_lockfile_content" do
subject(:updated_lockfile_content) { updater.updated_lockfile_content }

it "doesn't store the files permanently" do
expect { updated_lockfile_content }.
to_not(change { Dir.entries(tmp_path) })
end

it { expect { updated_lockfile_content }.to_not output.to_stdout }

context "when updating the lockfile fails" do
let(:csproj_body) { fixture("csproj", csproj_fixture_name).gsub('Version="0.11.1"', 'Version="99.99.99"') }

it "raises a helpful error" do
expect { updater.updated_lockfile_content }.
to raise_error do |error|
expect(error).
to be_a(Dependabot::SharedHelpers::HelperSubprocessFailed)
expect(error.message).to include(
"Failed to restore /home/dependabot/nuget/dependabot_tmp_dir/myproj.csproj"
)
expect(error.message).to include(
"error NU1102: Unable to find package Azure.Bicep.Core with version (>= 99.99.99)"
)
end
end
end

describe "the updated lockfile" do
it "updates the dependency version in the lockfile" do
prev_dependency = JSON.parse(lockfile_body)["dependencies"]["net6.0"]["Azure.Bicep.Core"]
new_dependency = JSON.parse(updated_lockfile_content)["dependencies"]["net6.0"]["Azure.Bicep.Core"]

expect(prev_dependency["resolved"]).to eql("0.7.4")
expect(prev_dependency["contentHash"]).to eql(
"G9FJNOcZBc74IQe7Uars6SVM8Kvum/ZJp0eyZ8Q47fiEn5+aBTFf36NkRKkknzT2bXkGubmwNa1copSiDAdzVg=="
)

expect(new_dependency["resolved"]).to eql("0.11.1")
expect(new_dependency["contentHash"]).to eql(
"S6NZBEy/D9UhN45XAiL8ZnUfzMLC/jTklcyd7/xizMhQYzMutcj6D9Dzseu2Svd4lgUFSelDHR7O62bn88niVw=="
)
end
end
end
end
Loading