switch to extracted bundler-multilock gem

well, somewhat. it's vendored for now, bugfixes and improvements
have been going into that gem, and we'd like those fixes in
Canvas.

Change-Id: Ib4f30926acddb364779b9f91b1ee129ba6b17ff0
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/327463
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Jeremy Stanley <jeremy@instructure.com>
Reviewed-by: Jacob Burroughs <jburroughs@instructure.com>
QA-Review: Cody Cutrer <cody@instructure.com>
Product-Review: Cody Cutrer <cody@instructure.com>
Build-Review: Cody Cutrer <cody@instructure.com>
This commit is contained in:
Cody Cutrer 2023-09-12 09:20:10 -06:00
parent d7e35b1ac0
commit 450d78155b
30 changed files with 938 additions and 1371 deletions

View File

@ -17,8 +17,8 @@ RUN --mount=target=/tmp/src \
gems/**/*.gemspec \
gems/**/gem_version.rb \
gems/**/version.rb \
gems/bundler_lockfile_extensions \
gems/plugins/**/Gemfile.d \
vendor/gems/bundler-multilock \
\
| tee /tmp/dst/ruby-runner.tar | md5sum > /tmp/dst/ruby-runner.tar.md5 && \
\

68
Gemfile
View File

@ -11,9 +11,19 @@
source "https://rubygems.org/"
plugin "bundler_lockfile_extensions", path: "gems/bundler_lockfile_extensions"
# cleanup local envs automatically from the old plugin
Plugin.uninstall(["bundler_lockfile_extensions"], {}) if Plugin.installed?("bundler_lockfile_extensions")
require File.expand_path("config/canvas_rails_switcher", __dir__)
# vendored until https://github.com/rubygems/rubygems/pull/6957 is merged and released
plugin "bundler-multilock", "1.0.7", path: "vendor/gems/bundler-multilock"
# the extra check here is in case `bundle check` or `bundle exec` gets run before `bundle install`,
# and is also fixed by the same PR
raise GemNotFound, "bundler-multilock plugin is not installed" if !is_a?(Bundler::Plugin::DSL) && !Plugin.installed?("bundler-multilock")
return unless Plugin.installed?("bundler-multilock")
Plugin.send(:load_plugin, "bundler-multilock")
require_relative "config/canvas_rails_switcher"
# Bundler evaluates this from a non-global context for plugins, so we have
# to explicitly pop up to set global constants
@ -22,47 +32,29 @@ require File.expand_path("config/canvas_rails_switcher", __dir__)
# will already be defined during the second Gemfile evaluation
::CANVAS_INCLUDE_PLUGINS = true unless defined?(::CANVAS_INCLUDE_PLUGINS)
if Plugin.installed?("bundler_lockfile_extensions")
Plugin.send(:load_plugin, "bundler_lockfile_extensions") unless defined?(BundlerLockfileExtensions)
SUPPORTED_RAILS_VERSIONS.product([nil, true]).each do |rails_version, include_plugins|
lockfile = ["rails#{rails_version.delete(".")}", include_plugins && "plugins"].compact.join(".")
lockfile = nil if rails_version == SUPPORTED_RAILS_VERSIONS.first && !include_plugins
unless BundlerLockfileExtensions.enabled?
default = true
SUPPORTED_RAILS_VERSIONS.product([nil, true]).each do |rails_version, include_plugins|
prepare = lambda do
Object.send(:remove_const, :CANVAS_RAILS)
::CANVAS_RAILS = rails_version
Object.send(:remove_const, :CANVAS_INCLUDE_PLUGINS)
::CANVAS_INCLUDE_PLUGINS = include_plugins
end
default = rails_version == CANVAS_RAILS && !!include_plugins
lockfile = ["Gemfile", "rails#{rails_version.delete(".")}", include_plugins && "plugins", "lock"].compact.join(".")
lockfile = nil if default
# only the first lockfile is the default
default = false
current = rails_version == CANVAS_RAILS && include_plugins
add_lockfile(lockfile,
current:,
prepare:,
allow_mismatched_dependencies: rails_version != SUPPORTED_RAILS_VERSIONS.first,
enforce_pinned_additional_dependencies: include_plugins)
end
Dir["Gemfile.d/*.lock", "gems/*/Gemfile.lock", base: Bundler.root].each do |gem_lockfile_name|
return unless add_lockfile(gem_lockfile_name,
gemfile: gem_lockfile_name.sub(/\.lock$/, ""),
allow_mismatched_dependencies: false)
end
lockfile(lockfile,
default:,
allow_mismatched_dependencies: rails_version != SUPPORTED_RAILS_VERSIONS.first,
enforce_pinned_additional_dependencies: include_plugins) do
Object.send(:remove_const, :CANVAS_RAILS)
::CANVAS_RAILS = rails_version
Object.send(:remove_const, :CANVAS_INCLUDE_PLUGINS)
::CANVAS_INCLUDE_PLUGINS = include_plugins
end
end
# rubocop:enable Style/RedundantConstantBase
# Bundler's first pass parses the entire Gemfile and calls to additional sources
# makes it actually go and retrieve metadata from them even though the plugin will
# never exist there. Short-circuit it here if we're in the plugin-specific DSL
# phase to prevent that from happening.
return if method(:source).owner == Bundler::Plugin::DSL
Dir["Gemfile.d/*.lock", "gems/*/Gemfile.lock", base: Bundler.root].each do |gem_lockfile_name|
return unless lockfile(gem_lockfile_name,
gemfile: gem_lockfile_name.sub(/\.lock$/, ""),
allow_mismatched_dependencies: false)
end
# rubocop:enable Style/RedundantConstantBase
module PreferGlobalRubyGemsSource
def rubygems_sources

View File

@ -72,7 +72,7 @@ def preBuild(stageConfig) {
stageConfig.value('addedOrDeletedSpecFiles', sh(script: 'git diff --name-only --diff-filter=AD HEAD^..HEAD | grep "_spec.rb"', returnStatus: true) == 0)
dir(env.LOCAL_WORKDIR) {
stageConfig.value('bundleFiles', sh(script: 'git diff --name-only HEAD^..HEAD | grep -E "Gemfile|gemspec|bundler_lockfile_extensions"', returnStatus: true) == 0)
stageConfig.value('bundleFiles', sh(script: 'git diff --name-only HEAD^..HEAD | grep -E "Gemfile|gemspec"', returnStatus: true) == 0)
stageConfig.value('specFiles', sh(script: "${WORKSPACE}/build/new-jenkins/spec-changes.sh", returnStatus: true) == 0)
}

View File

@ -1 +0,0 @@
--color

View File

@ -1,8 +0,0 @@
# frozen_string_literal: true
source "https://rubygems.org"
# Declare your gem's dependencies in broadcast_policy.gemspec.
# Bundler will treat runtime dependencies like base dependencies, and
# development dependencies will be added by default to the :development group.
gemspec

View File

@ -1,38 +0,0 @@
PATH
remote: .
specs:
bundler_lockfile_extensions (0.0.2)
GEM
remote: https://rubygems.org/
specs:
byebug (11.1.3)
diff-lcs (1.5.0)
rspec (3.12.0)
rspec-core (~> 3.12.0)
rspec-expectations (~> 3.12.0)
rspec-mocks (~> 3.12.0)
rspec-core (3.12.2)
rspec-support (~> 3.12.0)
rspec-expectations (3.12.3)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-mocks (3.12.6)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-support (3.12.1)
PLATFORMS
aarch64-linux
arm64-darwin
ruby
x86_64-darwin
x86_64-linux
DEPENDENCIES
bundler_lockfile_extensions!
byebug
rspec (~> 3.12)
BUNDLED WITH
2.4.19

View File

@ -1,16 +0,0 @@
# frozen_string_literal: true
Gem::Specification.new do |spec|
spec.name = "bundler_lockfile_extensions"
spec.version = "0.0.2"
spec.authors = ["Instructure"]
spec.summary = "Support Multiple Lockfiles"
spec.files = Dir.glob("{lib,spec}/**/*") + %w[plugins.rb]
spec.require_paths = ["lib"]
spec.add_dependency "bundler", ">= 2.3.26"
spec.add_development_dependency "byebug"
spec.add_development_dependency "rspec", "~> 3.12"
end

View File

@ -1,375 +0,0 @@
# frozen_string_literal: true
#
# Copyright (C) 2023 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require "bundler_lockfile_extensions/bundler"
require "bundler_lockfile_extensions/bundler/definition"
require "bundler_lockfile_extensions/bundler/dsl"
require "bundler_lockfile_extensions/bundler/source_list"
# Extends Bundler to allow arbitrarily many lockfiles (and Gemfiles!)
# for variations of the Gemfile, while keeping all of the lockfiles in sync.
#
# `bundle install`, `bundle lock`, and `bundle update` will operate only on
# the default lockfile (Gemfile.lock), afterwhich all other lockfiles will
# be re-created based on this default lockfile. Additional lockfiles can be
# based on the same Gemfile, but vary at runtime based on something like an
# environment variable, global variable, or constant. When defining such a
# lockfile, you should use a prepare callback that sets up the proper
# environment for that variation, even if that's not what would otherwise
# be selected by the launching environment.
#
# Alternately (or in addition!), you can define a lockfile to use a completely
# different Gemfile. This will have the effect that common dependencies between
# the two Gemfiles will stay locked to the same version in each lockfile.
#
# A lockfile definition can opt in to requiring explicit pinning for
# any dependency that exists in that variation, but does not exist in the default
# lockfile. This is especially useful if for some reason a given
# lockfile will _not_ be committed to version control (such as a variation
# that will include private plugins).
#
# Finally, `bundle check` will enforce additional checks to compare the final
# locked versions of dependencies between the various lockfiles to ensure
# they end up the same. This check might be tripped if Gemfile variations
# (accidentally!) have conflicting version constraints on a dependency, that
# are still self-consistent with that single Gemfile variation.
# `bundle install`, `bundle lock`, and `bundle update` will also verify these
# additional checks. You can additionally explicitly allow version variations
# between explicit dependencies (and their sub-dependencies), for cases where
# the lockfile variation is specifically to transition to a new version of
# a dependency (like a Rails upgrade).
#
module BundlerLockfileExtensions
class << self
attr_reader :lockfile_definitions
def enabled?
@lockfile_definitions
end
# @param lockfile [String] The lockfile path
# @param Builder [::Bundler::DSL] The Bundler DSL
# @param gemfile [String, nil]
# The Gemfile for this lockfile (defaults to Gemfile)
# @param current [true, false] If this is the currently active combination
# @param prepare [Proc, nil]
# The callback to set up the environment so your Gemfile knows this is
# the intended lockfile, and to select dependencies appropriately.
# @param allow_mismatched_dependencies [true, false]
# Allows version differences in dependencies between this lockfile and
# the default lockfile. Note that even with this option, only top-level
# dependencies that differ from the default lockfile, and their transitive
# depedencies, are allowed to mismatch.
# @param enforce_pinned_additional_dependencies [true, false]
# If dependencies are present in this lockfile that are not present in the
# default lockfile, enforce that they are pinned.
def add_lockfile(lockfile = nil,
builder:,
gemfile: nil,
current: false,
prepare: nil,
allow_mismatched_dependencies: true,
enforce_pinned_additional_dependencies: false)
enable unless enabled?
default = gemfile.nil? && lockfile.nil?
if default && default_lockfile_definition
raise ArgumentError, "Only one default lockfile (gemfile and lockfile unspecified) is allowed"
end
if current && @lockfile_definitions.any? { |definition| definition[:current] }
raise ArgumentError, "Only one lockfile can be flagged as the current lockfile"
end
@lockfile_definitions << (lockfile_def = {
gemfile: (gemfile && ::Bundler.root.join(gemfile).expand_path) || ::Bundler.default_gemfile,
lockfile: (lockfile && ::Bundler.root.join(lockfile).expand_path) || ::Bundler.default_lockfile,
default:,
current:,
prepare:,
allow_mismatched_dependencies:,
enforce_pinned_additional_dependencies:
}.freeze)
# if BUNDLE_LOCKFILE is specified, explicitly use only that lockfile, regardless of the command
if ENV["BUNDLE_LOCKFILE"]
if File.expand_path(ENV["BUNDLE_LOCKFILE"]) == lockfile_def[:lockfile].to_s
prepare&.call
set_lockfile = true
# we started evaluating the project's primary gemfile, but got told to use a lockfile
# associated with a different Gemfile. so we need to evaluate that Gemfile instead
if lockfile_def[:gemfile] != ::Bundler.default_gemfile
# share a cache between all lockfiles
::Bundler.cache_root = ::Bundler.root
ENV["BUNDLE_GEMFILE"] = lockfile_def[:gemfile].to_s
::Bundler.root = ::Bundler.default_gemfile.dirname
::Bundler.default_lockfile = lockfile_def[:lockfile]
builder.eval_gemfile(::Bundler.default_gemfile)
return false
end
end
else
# always use the default lockfile for `bundle check`, `bundle install`,
# `bundle lock`, and `bundle update`. `bundle cache` delegates to
# `bundle install`, but we want that to run as-normal.
set_lockfile = if (defined?(::Bundler::CLI::Check) ||
defined?(::Bundler::CLI::Install) ||
defined?(::Bundler::CLI::Lock) ||
defined?(::Bundler::CLI::Update)) &&
!defined?(::Bundler::CLI::Cache)
prepare&.call if default
default
else
current
end
end
::Bundler.default_lockfile = lockfile_def[:lockfile] if set_lockfile
true
end
# @!visibility private
def after_install_all(install: true)
previous_recursive = @recursive
return unless enabled?
return if ENV["BUNDLE_LOCKFILE"] # explicitly working against a single lockfile
# must be running `bundle cache`
return unless ::Bundler.default_lockfile == default_lockfile_definition[:lockfile]
require "bundler_lockfile_extensions/check"
if ::Bundler.frozen_bundle? && !install
# only do the checks if we're frozen
exit 1 unless Check.run
return
end
# this hook will be called recursively when it has to install gems
# for a secondary lockfile. defend against that
return if @recursive
@recursive = true
require "tempfile"
require "bundler_lockfile_extensions/lockfile_generator"
::Bundler.ui.info ""
default_lockfile_contents = ::Bundler.default_lockfile.read.freeze
default_specs = ::Bundler::LockfileParser.new(default_lockfile_contents).specs.to_h do |spec| # rubocop:disable Rails/IndexBy
[[spec.name, spec.platform], spec]
end
default_root = ::Bundler.root
attempts = 1
checker = Check.new
::Bundler.settings.temporary(cache_all_platforms: true, suppress_install_using_messages: true) do
@lockfile_definitions.each do |lockfile_definition|
# we already wrote the default lockfile
next if lockfile_definition[:default]
# root needs to be set so that paths are output relative to the correct root in the lockfile
::Bundler.root = lockfile_definition[:gemfile].dirname
relative_lockfile = lockfile_definition[:lockfile].relative_path_from(Dir.pwd)
# already up to date?
up_to_date = false
::Bundler.settings.temporary(frozen: true) do
::Bundler.ui.silence do
up_to_date = checker.base_check(lockfile_definition) && checker.check(lockfile_definition, allow_mismatched_dependencies: false)
end
end
if up_to_date
attempts = 1
next
end
if ::Bundler.frozen_bundle?
# if we're frozen, you have to use the pre-existing lockfile
unless lockfile_definition[:lockfile].exist?
::Bundler.ui.error("The bundle is locked, but #{relative_lockfile} is missing. Please make sure you have checked #{relative_lockfile} into version control before deploying.")
exit 1
end
::Bundler.ui.info("Installing gems for #{relative_lockfile}...")
write_lockfile(lockfile_definition, lockfile_definition[:lockfile], install:)
else
::Bundler.ui.info("Syncing to #{relative_lockfile}...") if attempts == 1
# adjust locked paths from the default lockfile to be relative to _this_ gemfile
adjusted_default_lockfile_contents = default_lockfile_contents.gsub(/PATH\n remote: ([^\n]+)\n/) do |remote|
remote_path = Pathname.new($1)
next remote if remote_path.absolute?
relative_remote_path = remote_path.expand_path(default_root).relative_path_from(::Bundler.root).to_s
remote.sub($1, relative_remote_path)
end
# add a source for the current gem
gem_spec = default_specs[[File.basename(::Bundler.root), "ruby"]]
if gem_spec
adjusted_default_lockfile_contents += <<~TEXT
PATH
remote: .
specs:
#{gem_spec.to_lock}
TEXT
end
if lockfile_definition[:lockfile].exist?
# if the lockfile already exists, "merge" it together
default_lockfile = ::Bundler::LockfileParser.new(adjusted_default_lockfile_contents)
lockfile = ::Bundler::LockfileParser.new(lockfile_definition[:lockfile].read)
dependency_changes = false
# replace any duplicate specs with what's in the default lockfile
lockfile.specs.map! do |spec|
default_spec = default_specs[[spec.name, spec.platform]]
next spec unless default_spec
dependency_changes ||= spec != default_spec
default_spec
end
lockfile.specs.replace(default_lockfile.specs + lockfile.specs).uniq!
lockfile.sources.replace(default_lockfile.sources + lockfile.sources).uniq!
lockfile.platforms.concat(default_lockfile.platforms).uniq!
# prune more specific platforms
lockfile.platforms.delete_if do |p1|
lockfile.platforms.any? { |p2| p2 != "ruby" && p1 != p2 && ::Bundler::MatchPlatform.platforms_match?(p2, p1) }
end
lockfile.instance_variable_set(:@ruby_version, default_lockfile.ruby_version)
lockfile.instance_variable_set(:@bundler_version, default_lockfile.bundler_version)
new_contents = LockfileGenerator.generate(lockfile)
else
# no lockfile? just start out with the default lockfile's contents to inherit its
# locked gems
new_contents = adjusted_default_lockfile_contents
end
had_changes = false
# Now build a definition based on the given Gemfile, with the combined lockfile
Tempfile.create do |temp_lockfile|
temp_lockfile.write(new_contents)
temp_lockfile.flush
had_changes = write_lockfile(lockfile_definition, temp_lockfile.path, install:, dependency_changes:)
end
# if we had changes, bundler may have updated some common
# dependencies beyond the default lockfile, so re-run it
# once to reset them back to the default lockfile's version.
# if it's already good, the `check` check at the beginning of
# the loop will skip the second sync anyway.
if had_changes && attempts < 3
attempts += 1
redo
else
attempts = 1
end
end
end
end
exit 1 unless checker.run
ensure
@recursive = previous_recursive
end
private
def enable
@lockfile_definitions ||= []
::Bundler.singleton_class.prepend(Bundler::ClassMethods)
::Bundler::Definition.prepend(Bundler::Definition)
::Bundler::SourceList.prepend(Bundler::SourceList)
end
def default_lockfile_definition
@default_lockfile_definition ||= @lockfile_definitions.find { |d| d[:default] }
end
def write_lockfile(lockfile_definition, lockfile, install:, dependency_changes: false)
lockfile_definition[:prepare]&.call
definition = ::Bundler::Definition.build(lockfile_definition[:gemfile], lockfile, false)
definition.instance_variable_set(:@dependency_changes, dependency_changes) if dependency_changes
resolved_remotely = false
begin
previous_ui_level = ::Bundler.ui.level
::Bundler.ui.level = "warn"
begin
definition.resolve_with_cache!
rescue ::Bundler::GemNotFound, ::Bundler::SolveFailure
definition = ::Bundler::Definition.build(lockfile_definition[:gemfile], lockfile, false)
definition.resolve_remotely!
resolved_remotely = true
end
definition.lock(lockfile_definition[:lockfile], true)
ensure
::Bundler.ui.level = previous_ui_level
end
# if we're running `bundle install` or `bundle update`, and something is missing from
# the secondary lockfile, install it.
if install && (definition.missing_specs.any? || resolved_remotely)
::Bundler.with_default_lockfile(lockfile_definition[:lockfile]) do
::Bundler::Installer.install(lockfile_definition[:gemfile].dirname, definition, {})
end
end
!definition.nothing_changed?
end
end
@recursive = false
end
Bundler::Dsl.include(BundlerLockfileExtensions::Bundler::Dsl)
# this is terrible, but we can't prepend into any module because we only load
# _inside_ of the CLI commands already running
if defined?(Bundler::CLI::Check)
require "bundler_lockfile_extensions/check"
at_exit do
next unless $!.nil?
next if $!.is_a?(SystemExit) && !$!.success?
next if BundlerLockfileExtensions::Check.run
Bundler.ui.warn("You can attempt to fix by running `bundle install`")
exit 1
end
end
if defined?(Bundler::CLI::Lock)
at_exit do
next unless $!.nil?
next if $!.is_a?(SystemExit) && !$!.success?
BundlerLockfileExtensions.after_install_all(install: false)
end
end

View File

@ -1,48 +0,0 @@
# frozen_string_literal: true
#
# Copyright (C) 2023 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
module BundlerLockfileExtensions
module Bundler
module ClassMethods
def self.prepended(klass)
super
klass.attr_writer :cache_root, :default_lockfile, :root
end
def app_cache(custom_path = nil)
super(custom_path || @cache_root)
end
def default_lockfile(force_original: false)
return @default_lockfile if @default_lockfile && !force_original
super()
end
def with_default_lockfile(lockfile)
previous_default_lockfile, @default_lockfile = @default_lockfile, lockfile
yield
ensure
@default_lockfile = previous_default_lockfile
end
end
end
end

View File

@ -1,38 +0,0 @@
# frozen_string_literal: true
#
# Copyright (C) 2023 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
module BundlerLockfileExtensions
module Bundler
module Definition
def initialize(lockfile, *args)
if ENV["BUNDLE_LOCKFILE"] && !::Bundler.instance_variable_get(:@default_lockfile)
raise ::Bundler::GemfileNotFound, "Could not locate lockfile #{ENV["BUNDLE_LOCKFILE"].inspect}"
end
# we changed the default lockfile in BundlerLockfileExtensions.add_lockfile
# since DSL.evaluate was called (re-entrantly); sub the proper value in
if !lockfile.equal?(::Bundler.default_lockfile) && ::Bundler.default_lockfile(force_original: true) == lockfile
lockfile = ::Bundler.default_lockfile
end
super
end
end
end
end

View File

@ -1,29 +0,0 @@
# frozen_string_literal: true
#
# Copyright (C) 2023 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
module BundlerLockfileExtensions
module Bundler
module Dsl
def add_lockfile(*args, **kwargs)
BundlerLockfileExtensions.add_lockfile(*args, builder: self, **kwargs)
end
end
end
end

View File

@ -1,30 +0,0 @@
# frozen_string_literal: true
#
# Copyright (C) 2023 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
module BundlerLockfileExtensions
module Bundler
module SourceList
# consider them equivalent if the replacements just have a bunch of dups
def equivalent_sources?(lock_sources, replacement_sources)
super(lock_sources, replacement_sources.uniq)
end
end
end
end

View File

@ -1,182 +0,0 @@
# frozen_string_literal: true
#
# Copyright (C) 2023 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require "set"
module BundlerLockfileExtensions
class Check
class << self
def run # rubocop:disable Rails/Delegate
new.run
end
end
def initialize
default_lockfile_contents = ::Bundler.default_lockfile.read
@default_lockfile = ::Bundler::LockfileParser.new(default_lockfile_contents)
@default_specs = @default_lockfile.specs.to_h do |spec| # rubocop:disable Rails/IndexBy
[[spec.name, spec.platform], spec]
end
end
def run
return true unless ::Bundler.default_lockfile.exist?
success = true
BundlerLockfileExtensions.lockfile_definitions.each do |lockfile_definition|
next unless lockfile_definition[:lockfile].exist?
success = false unless check(lockfile_definition)
end
success
end
# this is mostly equivalent to the built in checks in `bundle check`, but even
# more conservative, and returns false instead of exiting on failure
def base_check(lockfile_definition)
return false unless lockfile_definition[:lockfile].file?
lockfile_definition[:prepare]&.call
definition = ::Bundler::Definition.build(lockfile_definition[:gemfile], lockfile_definition[:lockfile], false)
return false unless definition.send(:current_platform_locked?)
begin
definition.validate_runtime!
definition.resolve_only_locally!
not_installed = definition.missing_specs
rescue ::Bundler::RubyVersionMismatch, ::Bundler::GemNotFound, ::Bundler::SolveFailure
return false
end
not_installed.empty?
end
# this checks for mismatches between the default lockfile and the given lockfile,
# and for pinned dependencies in lockfiles requiring them
def check(lockfile_definition, allow_mismatched_dependencies: true)
success = true
proven_pinned = Set.new
needs_pin_check = []
lockfile = ::Bundler::LockfileParser.new(lockfile_definition[:lockfile].read)
unless lockfile.platforms == @default_lockfile.platforms
::Bundler.ui.error("The platforms in #{lockfile_definition[:lockfile].relative_path_from(Dir.pwd)} do not match the default lockfile.")
success = false
end
unless lockfile.bundler_version == @default_lockfile.bundler_version
::Bundler.ui.error("bundler (#{lockfile.bundler_version}) in #{lockfile_definition[:lockfile].relative_path_from(Dir.pwd)} does not match the default lockfile's version (@#{@default_lockfile.bundler_version}).")
success = false
end
specs = lockfile.specs.group_by(&:name)
allow_mismatched_dependencies = lockfile_definition[:allow_mismatched_dependencies] if allow_mismatched_dependencies
# build list of top-level dependencies that differ from the default lockfile,
# and all _their_ transitive dependencies
if allow_mismatched_dependencies
transitive_dependencies = Set.new
# only dependencies that differ from the default lockfile
pending_transitive_dependencies = lockfile.dependencies.reject do |name, dep|
@default_lockfile.dependencies[name] == dep
end.map(&:first)
until pending_transitive_dependencies.empty?
dep = pending_transitive_dependencies.shift
next if transitive_dependencies.include?(dep)
transitive_dependencies << dep
platform_specs = specs[dep]
unless platform_specs
# should only be bundler that's missing a spec
raise "Could not find spec for dependency #{dep}" unless dep == "bundler"
next
end
pending_transitive_dependencies.concat(platform_specs.flat_map(&:dependencies).map(&:name).uniq)
end
end
# look through top-level explicit dependencies for pinned requirements
if lockfile_definition[:enforce_pinned_additional_dependencies]
find_pinned_dependencies(proven_pinned, lockfile.dependencies.each_value)
end
# check for conflicting requirements (and build list of pins, in the same loop)
specs.values.flatten.each do |spec|
default_spec = @default_specs[[spec.name, spec.platform]]
if lockfile_definition[:enforce_pinned_additional_dependencies]
# look through what this spec depends on, and keep track of all pinned requirements
find_pinned_dependencies(proven_pinned, spec.dependencies)
needs_pin_check << spec unless default_spec
end
next unless default_spec
# have to ensure Path sources are relative to their lockfile before comparing
same_source = if [default_spec.source, spec.source].grep(::Bundler::Source::Path).length == 2
lockfile_definition[:lockfile].dirname.join(spec.source.path).ascend.any?(::Bundler.default_lockfile.dirname.join(default_spec.source.path))
else
default_spec.source == spec.source
end
next if default_spec.version == spec.version && same_source
next if allow_mismatched_dependencies && transitive_dependencies.include?(spec.name)
::Bundler.ui.error("#{spec}#{spec.git_version} in #{lockfile_definition[:lockfile].relative_path_from(Dir.pwd)} does not match the default lockfile's version (@#{default_spec.version}#{default_spec.git_version}); this may be due to a conflicting requirement, which would require manual resolution.")
success = false
end
# now that we have built a list of every gem that is pinned, go through
# the gems that were in this lockfile, but not the default lockfile, and
# ensure it's pinned _somehow_
needs_pin_check.each do |spec|
pinned = case spec.source
when ::Bundler::Source::Git
spec.source.ref == spec.source.revision
when ::Bundler::Source::Path
true
when ::Bundler::Source::Rubygems
proven_pinned.include?(spec.name)
else
false
end
unless pinned
::Bundler.ui.error("#{spec} in #{lockfile_definition[:lockfile].relative_path_from(Dir.pwd)} has not been pinned to a specific version, which is required since it is not part of the default lockfile.")
success = false
end
end
success
end
private
def find_pinned_dependencies(proven_pinned, dependencies)
dependencies.each do |dependency|
dependency.requirement.requirements.each do |requirement|
proven_pinned << dependency.name if requirement.first == "="
end
end
end
end
end

View File

@ -1,60 +0,0 @@
# frozen_string_literal: true
#
# Copyright (C) 2023 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require "bundler/lockfile_generator"
module BundlerLockfileExtensions
# generates a lockfile based on another LockfileParser
class LockfileGenerator < ::Bundler::LockfileGenerator
def self.generate(lockfile)
new(LockfileAdapter.new(lockfile)).generate!
end
private
class LockfileAdapter < SimpleDelegator
def sources
self
end
def lock_sources
__getobj__.sources
end
def resolve
specs
end
def dependencies
super.values
end
def locked_ruby_version
ruby_version
end
end
private_constant :LockfileAdapter
def add_bundled_with
add_section("BUNDLED WITH", definition.bundler_version.to_s)
end
end
end

View File

@ -1,470 +0,0 @@
# frozen_string_literal: true
#
# Copyright (C) 2023 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
require "tempfile"
require "open3"
require "fileutils"
require_relative "spec_helper"
describe "BundlerLockfileExtensions" do
# the definition section for a gemfile with two lockfiles; one
# that will have more gems than the default.
let(:all_gems_definitions) do
<<~RUBY
add_lockfile(
prepare: -> { ::INCLUDE_ALL_GEMS = false },
current: false
)
add_lockfile(
"Gemfile.full.lock",
prepare: -> { ::INCLUDE_ALL_GEMS = true },
current: true
)
RUBY
end
let(:all_gems_preamble) do
"::INCLUDE_ALL_GEMS = true unless defined?(::INCLUDE_ALL_GEMS)"
end
it "generates a default Gemfile.lock when loaded, but not configured" do
contents = <<~RUBY
gem "concurrent-ruby", "1.2.2"
RUBY
with_gemfile("", contents) do
invoke_bundler("install")
output = invoke_bundler("info concurrent-ruby")
expect(output).to include("1.2.2")
expect(File.read("Gemfile.lock")).to include("1.2.2")
end
end
it "disallows multiple default lockfiles" do
with_gemfile(<<~RUBY) do
add_lockfile()
add_lockfile()
RUBY
expect { invoke_bundler("install") }.to raise_error(/Only one default lockfile/)
end
end
it "disallows multiple current lockfiles" do
with_gemfile(<<~RUBY) do
add_lockfile(current: true)
add_lockfile("Gemfile.new.lock", current: true)
RUBY
expect { invoke_bundler("install") }.to raise_error(/Only one lockfile/)
end
end
it "generates custom lockfiles with varying versions" do
definitions = <<~RUBY
add_lockfile(
prepare: -> { ::GEM_VERSION = "1.1.10" }
)
add_lockfile(
"Gemfile.new.lock",
prepare: -> { ::GEM_VERSION = "1.2.2" }
)
RUBY
contents = <<~RUBY
::GEM_VERSION = "1.1.10" unless defined?(::GEM_VERSION)
gem "concurrent-ruby", ::GEM_VERSION
RUBY
with_gemfile(definitions, contents) do
invoke_bundler("install")
expect(File.read("Gemfile.lock")).to include("1.1.10")
expect(File.read("Gemfile.lock")).not_to include("1.2.2")
expect(File.read("Gemfile.new.lock")).not_to include("1.1.10")
expect(File.read("Gemfile.new.lock")).to include("1.2.2")
end
end
it "generates lockfiles with a subset of gems" do
contents = <<~RUBY
if ::INCLUDE_ALL_GEMS
gem "test_local", path: "test_local"
end
gem "concurrent-ruby", "1.2.2"
RUBY
with_gemfile(all_gems_definitions, contents, all_gems_preamble) do
create_local_gem("test_local", "")
invoke_bundler("install")
expect(File.read("Gemfile.lock")).not_to include("test_local")
expect(File.read("Gemfile.full.lock")).to include("test_local")
expect(File.read("Gemfile.lock")).to include("concurrent-ruby")
expect(File.read("Gemfile.full.lock")).to include("concurrent-ruby")
end
end
it "fails if an additional lockfile contains an invalid gem" do
definitions = <<~RUBY
add_lockfile()
add_lockfile(
"Gemfile.new.lock"
)
RUBY
contents = <<~RUBY
gem "concurrent-ruby", ">= 1.2.2"
RUBY
with_gemfile(definitions, contents) do
invoke_bundler("install")
replace_lockfile_pin("Gemfile.new.lock", "concurrent-ruby", "9.9.9")
expect { invoke_bundler("check") }.to raise_error(/concurrent-ruby.*does not match/m)
end
end
it "preserves the locked version of a gem in an alternate lockfile when updating a different gem in common" do
contents = <<~RUBY
gem "net-ldap", "0.17.0"
if ::INCLUDE_ALL_GEMS
gem "net-smtp", "0.3.2"
end
RUBY
with_gemfile(all_gems_definitions, contents, all_gems_preamble) do
invoke_bundler("install")
expect(invoke_bundler("info net-ldap")).to include("0.17.0")
expect(invoke_bundler("info net-smtp")).to include("0.3.2")
# loosen the requirement on both gems
write_gemfile(all_gems_definitions, <<~RUBY, all_gems_preamble)
gem "net-ldap", "~> 0.17"
if ::INCLUDE_ALL_GEMS
gem "net-smtp", "~> 0.3"
end
RUBY
# but only update net-ldap
invoke_bundler("update net-ldap")
# net-smtp should be untouched, even though it's no longer pinned
expect(invoke_bundler("info net-ldap")).not_to include("0.17.0")
expect(invoke_bundler("info net-smtp")).to include("0.3.2")
end
end
it "maintains consistency across multiple Gemfiles" do
definitions = <<~RUBY
add_lockfile()
add_lockfile(
"local_test/Gemfile.lock",
gemfile: "local_test/Gemfile")
RUBY
contents = <<~RUBY
gem "net-smtp", "0.3.2"
RUBY
with_gemfile(definitions, contents) do
create_local_gem("local_test", <<~RUBY)
spec.add_dependency "net-smtp", "~> 0.3"
RUBY
invoke_bundler("install")
# locks to 0.3.2 in the local gem's lockfile, even though the local
# gem itself would allow newer
expect(File.read("local_test/Gemfile.lock")).to include("0.3.2")
end
end
it "whines about non-pinned dependencies in flagged gemfiles" do
definitions = <<~RUBY
add_lockfile(
prepare: -> { ::INCLUDE_ALL_GEMS = false },
current: false
)
add_lockfile(
"Gemfile.full.lock",
prepare: -> { ::INCLUDE_ALL_GEMS = true },
current: true,
enforce_pinned_additional_dependencies: true
)
RUBY
contents = <<~RUBY
gem "net-ldap", "0.17.0"
if ::INCLUDE_ALL_GEMS
gem "net-smtp", "~> 0.3"
end
RUBY
with_gemfile(definitions, contents, all_gems_preamble) do
expect { invoke_bundler("install") }.to raise_error(/net-smtp \([0-9.]+\) in Gemfile.full.lock has not been pinned/)
# not only have to pin net-smtp, but also its transitive dependencies
write_gemfile(definitions, <<~RUBY, all_gems_preamble)
gem "net-ldap", "0.17.0"
if ::INCLUDE_ALL_GEMS
gem "net-smtp", "0.3.2"
gem "net-protocol", "0.2.1"
gem "timeout", "0.3.2"
end
RUBY
invoke_bundler("install") # no error, because it's now pinned
end
end
context "with mismatched dependencies disallowed" do
let(:all_gems_definitions) do
<<~RUBY
add_lockfile(
prepare: -> { ::INCLUDE_ALL_GEMS = false },
current: false
)
add_lockfile(
"Gemfile.full.lock",
prepare: -> { ::INCLUDE_ALL_GEMS = true },
allow_mismatched_dependencies: false,
current: true
)
RUBY
end
it "notifies about mismatched versions between different lockfiles" do
contents = <<~RUBY
if ::INCLUDE_ALL_GEMS
gem "activesupport", "7.0.4.3"
else
gem "activesupport", "~> 6.1.0"
end
RUBY
with_gemfile(all_gems_definitions, contents, all_gems_preamble) do
expect { invoke_bundler("install") }.to raise_error(/activesupport \(7.0.4.3\) in Gemfile.full.lock does not match the default lockfile's version/)
end
end
it "notifies about mismatched versions between different lockfiles for sub-dependencies" do
definitions = <<~RUBY
add_lockfile(
prepare: -> { ::INCLUDE_ALL_GEMS = false },
current: false
)
add_lockfile(
"Gemfile.full.lock",
prepare: -> { ::INCLUDE_ALL_GEMS = true },
allow_mismatched_dependencies: false,
current: true
)
RUBY
contents = <<~RUBY
gem "activesupport", "7.0.4.3" # depends on tzinfo ~> 2.0, so will get >= 2.0.6
if ::INCLUDE_ALL_GEMS
gem "tzinfo", "2.0.5"
end
RUBY
with_gemfile(definitions, contents, all_gems_preamble) do
expect { invoke_bundler("install") }.to raise_error(/tzinfo \(2.0.5\) in Gemfile.full.lock does not match the default lockfile's version/)
end
end
end
it "allows mismatched explicit dependencies by default" do
contents = <<~RUBY
if ::INCLUDE_ALL_GEMS
gem "activesupport", "7.0.4.3"
else
gem "activesupport", "~> 6.1.0"
end
RUBY
with_gemfile(all_gems_definitions, contents, all_gems_preamble) do
invoke_bundler("install") # no error
expect(File.read("Gemfile.lock")).to include("6.1.")
expect(File.read("Gemfile.lock")).not_to include("7.0.4.3")
expect(File.read("Gemfile.full.lock")).not_to include("6.1.")
expect(File.read("Gemfile.full.lock")).to include("7.0.4.3")
end
end
it "disallows mismatched implicit dependencies" do
definitions = <<~RUBY
add_lockfile()
add_lockfile(
"local_test/Gemfile.lock",
allow_mismatched_dependencies: false,
gemfile: "local_test/Gemfile")
RUBY
contents = <<~RUBY
gem "snaky_hash", "2.0.1"
RUBY
with_gemfile(definitions, contents) do
create_local_gem("local_test", <<~RUBY)
spec.add_dependency "zendesk_api", "1.28.0"
RUBY
expect { invoke_bundler("install") }.to raise_error(%r{hashie \(4[0-9.]+\) in local_test/Gemfile.lock does not match the default lockfile's version \(@([0-9.]+)\)})
end
end
it "removes transitive deps from secondary lockfiles when they disappear from the primary lockfile" do
contents = <<~RUBY
gem "pact-mock_service", "3.11.0"
RUBY
with_gemfile(all_gems_definitions, contents, all_gems_preamble) do
# get 3.11.0 intalled
invoke_bundler("install")
write_gemfile(all_gems_definitions, <<~RUBY, all_gems_preamble)
gem "pact-mock_service", "~> 3.11.0"
RUBY
# update the lockfiles with the looser dependency (but with 3.11.0)
invoke_bundler("install")
expect(File.read("Gemfile.lock")).to include("filelock")
full_lock = File.read("Gemfile.full.lock")
expect(full_lock).to include("filelock")
# update the default lockfile to 3.11.2
invoke_bundler("update")
# but revert the full lockfile, and re-sync it
# as part of a regular bundle install
File.write("Gemfile.full.lock", full_lock)
invoke_bundler("install")
expect(File.read("Gemfile.lock")).not_to include("filelock")
expect(File.read("Gemfile.full.lock")).not_to include("filelock")
end
end
private
def create_local_gem(name, content)
FileUtils.mkdir_p(name)
File.write("#{name}/#{name}.gemspec", <<~RUBY)
Gem::Specification.new do |spec|
spec.name = #{name.inspect}
spec.version = "0.0.1"
spec.authors = ["Instructure"]
spec.summary = "for testing only"
#{content}
end
RUBY
File.write("#{name}/Gemfile", <<~RUBY)
source "https://rubygems.org"
gemspec
RUBY
end
# creates a new temporary directory, writes the gemfile to it, and yields
#
# @param (see #write_gemfile)
# @yield
def with_gemfile(definitions, content = nil, preamble = nil)
Dir.mktmpdir do |dir|
Dir.chdir(dir) do
write_gemfile(definitions, content, preamble)
invoke_bundler("config frozen false")
yield
end
end
end
# @param definitions [String]
# Ruby code to set up lockfiles by calling add_lockfile. Called inside a
# conditional for when BundlerLockfileExtensions is loaded the first time.
# @param content [String]
# Additional Ruby code for adding gem requirements to the Gemfile
# @param preamble [String]
# Additional Ruby code to execute prior to installing the plugin.
def write_gemfile(definitions, content = nil, preamble = nil)
raise ArgumentError, "Did you mean to use `with_gemfile`?" if block_given?
File.write("Gemfile", <<~RUBY)
source "https://rubygems.org"
#{preamble}
plugin "bundler_lockfile_extensions", path: #{File.dirname(__dir__).inspect}
if Plugin.installed?("bundler_lockfile_extensions")
Plugin.send(:load_plugin, "bundler_lockfile_extensions") unless defined?(BundlerLockfileExtensions)
unless BundlerLockfileExtensions.enabled?
#{definitions}
end
end
#{content}
RUBY
end
# Shells out to a new instance of bundler, with a clean bundler env
#
# @param subcommand [String] Args to pass to bundler
# @raise [RuntimeError] if the bundle command fails
def invoke_bundler(subcommand, env: {})
output = nil
bundler_version = ENV.fetch("BUNDLER_VERSION")
command = "#{Gem.bin_path("bundler", "bundler", bundler_version)} #{subcommand}"
Bundler.with_unbundled_env do
output, status = Open3.capture2e(env, command)
raise "bundle #{subcommand} failed: #{output}" unless status.success?
end
output
end
# Directly modifies a lockfile to adjust the version of a gem
#
# Useful for simulating certain unusual situations that can arise.
#
# @param lockfile [String] The lockfile's location
# @param gem [String] The gem's name
# @param version [String] The new version to "pin" the gem to
def replace_lockfile_pin(lockfile, gem, version)
new_contents = File.read(lockfile).gsub(%r{#{gem} \([0-9.]+\)}, "#{gem} (#{version})")
File.write(lockfile, new_contents)
end
end

View File

@ -1,28 +0,0 @@
# frozen_string_literal: true
#
# Copyright (C) 2023 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
RSpec.configure do |config|
config.expect_with :rspec do |c|
c.max_formatted_output_length = nil
end
config.run_all_when_everything_filtered = true
config.filter_run :focus
config.order = "random"
end

View File

@ -1,5 +0,0 @@
#!/bin/bash
set -e
bundle check || bundle install
bundle exec rspec spec

View File

@ -21,6 +21,7 @@ CopyrightLinter:
- "**/vendor/**"
- "gems/tatl_tael/spec/lib/tatl_tael/linters/fixtures/**"
- "gems/plugins/**"
- "vendor/**"
Regexes:
FirstLineExceptions:
- !ruby/regexp '/^#!/' # e.g. "#!/usr/bin/env ruby"

View File

@ -15,7 +15,7 @@ require "optparse"
linter_options = {
linter_name: "Rubocop",
file_regex: %r{(?:\.rb|\.rake|\.gemspec|/[^./]+)$},
file_regex: %r{(?:\.rb|\.rake|\.gemspec|Gemfile|/[^./]+)$},
format: "rubocop",
command: +"bin/rubocop --force-exclusion",
auto_correct: false,

View File

@ -0,0 +1,437 @@
# frozen_string_literal: true
require_relative "multilock/ext/bundler"
require_relative "multilock/ext/definition"
require_relative "multilock/ext/dsl"
require_relative "multilock/ext/plugin"
require_relative "multilock/ext/plugin/dsl"
require_relative "multilock/ext/source_list"
require_relative "multilock/version"
module Bundler
module Multilock
class << self
# @!visibility private
attr_reader :lockfile_definitions
# @!visibility private
attr_accessor :prepare_block
# @param lockfile [String] The lockfile path (defaults to Gemfile.lock)
# @param builder [Dsl] The Bundler DSL
# @param gemfile [String, nil]
# The Gemfile for this lockfile (defaults to Gemfile)
# @param default [Boolean]
# If this lockfile should be the default (instead of Gemfile.lock)
# @param allow_mismatched_dependencies [true, false]
# Allows version differences in dependencies between this lockfile and
# the default lockfile. Note that even with this option, only top-level
# dependencies that differ from the default lockfile, and their transitive
# depedencies, are allowed to mismatch.
# @param enforce_pinned_additional_dependencies [true, false]
# If dependencies are present in this lockfile that are not present in the
# default lockfile, enforce that they are pinned.
# @yield
# Block executed only when this lockfile is active.
# @return [true, false] if the lockfile is the current lockfile
def add_lockfile(lockfile = nil,
builder:,
gemfile: nil,
default: nil,
allow_mismatched_dependencies: true,
enforce_pinned_additional_dependencies: false,
&block)
# terminology gets confusing here. The "default" param means
# "use this lockfile when not overridden by BUNDLE_LOCKFILE"
# but Bundler.defaul_lockfile (usually) means "Gemfile.lock"
# so refer to the former as "current" internally
current = default
current = true if current.nil? && lockfile_definitions.empty? && lockfile.nil? && gemfile.nil?
# allow short-form lockfile names
lockfile = "Gemfile.#{lockfile}.lock" if lockfile && !(lockfile.include?("/") || lockfile.end_with?(".lock"))
# if a gemfile was provided, but not a lockfile, infer the default lockfile for that gemfile
lockfile ||= "#{gemfile}.lock" if gemfile
# use absolute paths
lockfile = Bundler.root.join(lockfile).expand_path if lockfile
# use the default lockfile (Gemfile.lock) if none was given
lockfile ||= Bundler.default_lockfile(force_original: true)
if current && (old_current = lockfile_definitions.find { |definition| definition[:current] })
raise ArgumentError, "Only one lockfile (#{old_current[:lockfile]}) can be flagged as the default"
end
raise ArgumentError, "Lockfile #{lockfile} is already defined" if lockfile_definitions.any? do |definition|
definition[:lockfile] == lockfile
end
env_lockfile = ENV["BUNDLE_LOCKFILE"]
if env_lockfile
unless env_lockfile.include?("/") || env_lockfile.end_with?(".lock")
env_lockfile = "Gemfile.#{env_lockfile}.lock"
end
env_lockfile = Bundler.root.join(env_lockfile).expand_path
current = env_lockfile == lockfile
end
lockfile_definitions << (lockfile_def = {
gemfile: (gemfile && Bundler.root.join(gemfile).expand_path) || Bundler.default_gemfile,
lockfile: lockfile,
current: current,
prepare: block,
allow_mismatched_dependencies: allow_mismatched_dependencies,
enforce_pinned_additional_dependencies: enforce_pinned_additional_dependencies
})
if (defined?(CLI::Check) ||
defined?(CLI::Install) ||
defined?(CLI::Lock) ||
defined?(CLI::Update)) &&
!defined?(CLI::Cache) && !env_lockfile
# always use Gemfile.lock for `bundle check`, `bundle install`,
# `bundle lock`, and `bundle update`. `bundle cache` delegates to
# `bundle install`, but we want that to run as normal.
# If they're using BUNDLE_LOCKFILE, then they really do want to
# use a particular lockfile, and it overrides whatever they
# dynamically set in their gemfile
current = lockfile == Bundler.default_lockfile(force_original: true)
end
if current
block&.call
Bundler.default_lockfile = lockfile
# we started evaluating the project's primary gemfile, but got told to use a lockfile
# associated with a different Gemfile. so we need to evaluate that Gemfile instead
if lockfile_def[:gemfile] != Bundler.default_gemfile
# share a cache between all lockfiles
Bundler.cache_root = Bundler.root
ENV["BUNDLE_GEMFILE"] = lockfile_def[:gemfile].to_s
Bundler.root = Bundler.default_gemfile.dirname
Bundler.default_lockfile = lockfile
builder.eval_gemfile(Bundler.default_gemfile)
return false
end
end
true
end
# @!visibility private
def after_install_all(install: true)
loaded!
previous_recursive = @recursive
return if lockfile_definitions.empty?
return if ENV["BUNDLE_LOCKFILE"] # explicitly working against a single lockfile
# must be running `bundle cache`
return unless Bundler.default_lockfile == Bundler.default_lockfile(force_original: true)
require_relative "multilock/check"
if Bundler.frozen_bundle? && !install
# only do the checks if we're frozen
exit 1 unless Check.run
return
end
# this hook will be called recursively when it has to install gems
# for a secondary lockfile. defend against that
return if @recursive
@recursive = true
require "tempfile"
require_relative "multilock/lockfile_generator"
Bundler.ui.info ""
default_lockfile_contents = Bundler.default_lockfile.read.freeze
default_specs = LockfileParser.new(default_lockfile_contents).specs.to_h do |spec|
[[spec.name, spec.platform], spec]
end
default_root = Bundler.root
attempts = 1
checker = Check.new
synced_any = false
Bundler.settings.temporary(cache_all_platforms: true, suppress_install_using_messages: true) do
lockfile_definitions.each do |lockfile_definition|
# we already wrote the default lockfile
next if lockfile_definition[:lockfile] == Bundler.default_lockfile(force_original: true)
# root needs to be set so that paths are output relative to the correct root in the lockfile
Bundler.root = lockfile_definition[:gemfile].dirname
relative_lockfile = lockfile_definition[:lockfile].relative_path_from(Dir.pwd)
# already up to date?
up_to_date = false
Bundler.settings.temporary(frozen: true) do
Bundler.ui.silence do
up_to_date = checker.base_check(lockfile_definition) &&
checker.check(lockfile_definition, allow_mismatched_dependencies: false)
end
end
if up_to_date
attempts = 1
next
end
if Bundler.frozen_bundle?
# if we're frozen, you have to use the pre-existing lockfile
unless lockfile_definition[:lockfile].exist?
Bundler.ui.error("The bundle is locked, but #{relative_lockfile} is missing. " \
"Please make sure you have checked #{relative_lockfile} " \
"into version control before deploying.")
exit 1
end
Bundler.ui.info("Installing gems for #{relative_lockfile}...")
write_lockfile(lockfile_definition, lockfile_definition[:lockfile], install: install)
else
Bundler.ui.info("Syncing to #{relative_lockfile}...") if attempts == 1
synced_any = true
# adjust locked paths from the default lockfile to be relative to _this_ gemfile
adjusted_default_lockfile_contents =
default_lockfile_contents.gsub(/PATH\n remote: ([^\n]+)\n/) do |remote|
remote_path = Pathname.new($1)
next remote if remote_path.absolute?
relative_remote_path = remote_path.expand_path(default_root).relative_path_from(Bundler.root).to_s
remote.sub($1, relative_remote_path)
end
# add a source for the current gem
gem_spec = default_specs[[File.basename(Bundler.root), "ruby"]]
if gem_spec
adjusted_default_lockfile_contents += <<~TEXT
PATH
remote: .
specs:
#{gem_spec.to_lock}
TEXT
end
if lockfile_definition[:lockfile].exist?
# if the lockfile already exists, "merge" it together
default_lockfile = LockfileParser.new(adjusted_default_lockfile_contents)
lockfile = LockfileParser.new(lockfile_definition[:lockfile].read)
dependency_changes = false
# replace any duplicate specs with what's in the default lockfile
lockfile.specs.map! do |spec|
default_spec = default_specs[[spec.name, spec.platform]]
next spec unless default_spec
dependency_changes ||= spec != default_spec
default_spec
end
lockfile.specs.replace(default_lockfile.specs + lockfile.specs).uniq!
lockfile.sources.replace(default_lockfile.sources + lockfile.sources).uniq!
lockfile.platforms.replace(default_lockfile.platforms).uniq!
# prune more specific platforms
lockfile.platforms.delete_if do |p1|
lockfile.platforms.any? do |p2|
p2 != "ruby" && p1 != p2 && MatchPlatform.platforms_match?(p2, p1)
end
end
lockfile.instance_variable_set(:@ruby_version, default_lockfile.ruby_version)
lockfile.instance_variable_set(:@bundler_version, default_lockfile.bundler_version)
new_contents = LockfileGenerator.generate(lockfile)
else
# no lockfile? just start out with the default lockfile's contents to inherit its
# locked gems
new_contents = adjusted_default_lockfile_contents
end
had_changes = false
# Now build a definition based on the given Gemfile, with the combined lockfile
Tempfile.create do |temp_lockfile|
temp_lockfile.write(new_contents)
temp_lockfile.flush
had_changes = write_lockfile(lockfile_definition,
temp_lockfile.path,
install: install,
dependency_changes: dependency_changes)
end
# if we had changes, bundler may have updated some common
# dependencies beyond the default lockfile, so re-run it
# once to reset them back to the default lockfile's version.
# if it's already good, the `check` check at the beginning of
# the loop will skip the second sync anyway.
if had_changes && attempts < 3
attempts += 1
redo
else
attempts = 1
end
end
end
ensure
Bundler.root = default_root
end
exit 1 unless checker.run(skip_base_checks: !synced_any)
ensure
@recursive = previous_recursive
end
# @!visibility private
def loaded!
return if loaded?
@loaded = true
return if lockfile_definitions.empty?
return unless lockfile_definitions.none? { |definition| definition[:current] }
# Gemfile.lock isn't explicitly specified, otherwise it would be current
default_lockfile_definition = lockfile_definitions.find do |definition|
definition[:lockfile] == Bundler.default_lockfile(force_original: true)
end
if ENV["BUNDLE_LOCKFILE"] == Bundler.default_lockfile(force_original: true) && default_lockfile_definition
return
end
raise GemfileNotFound, "Could not locate lockfile #{ENV["BUNDLE_LOCKFILE"].inspect}" if ENV["BUNDLE_LOCKFILE"]
return unless default_lockfile_definition && default_lockfile_definition[:current] == false
raise GemfileEvalError, "No lockfiles marked as default"
end
# @!visibility private
def loaded?
@loaded
end
# @!visibility private
def inject_preamble
minor_version = Gem::Version.new(::Bundler::Multilock::VERSION).segments[0..1].join(".")
bundle_preamble1_match = %(plugin "bundler-multilock")
bundle_preamble1 = <<~RUBY
plugin "bundler-multilock", "~> #{minor_version}"
RUBY
bundle_preamble2 = <<~RUBY
return unless Plugin.installed?("bundler-multilock")
Plugin.send(:load_plugin, "bundler-multilock")
RUBY
gemfile = Bundler.default_gemfile.read
injection_point = 0
while gemfile.match?(/^(?:#|\n|source)/, injection_point)
if gemfile[injection_point] == "\n"
injection_point += 1
else
injection_point = gemfile.index("\n", injection_point)
injection_point += 1 if injection_point
injection_point ||= -1
end
end
modified = inject_specific_preamble(gemfile, injection_point, bundle_preamble2, add_newline: true)
modified = true if inject_specific_preamble(gemfile,
injection_point,
bundle_preamble1,
match: bundle_preamble1_match,
add_newline: false)
Bundler.default_gemfile.write(gemfile) if modified
end
# @!visibility private
def reset!
@lockfile_definitions = []
@loaded = false
end
private
def inject_specific_preamble(gemfile, injection_point, preamble, add_newline:, match: preamble)
return false if gemfile.include?(match)
add_newline = false unless gemfile[injection_point - 1] == "\n"
gemfile.insert(injection_point, "\n") if add_newline
gemfile.insert(injection_point, preamble)
true
end
def write_lockfile(lockfile_definition, lockfile, install:, dependency_changes: false)
prepare_block = lockfile_definition[:prepare]
gemfile = lockfile_definition[:gemfile]
# use avoid Definition.build, so that we don't have to evaluate
# the gemfile multiple times, each time we need a separate definition
builder = Dsl.new
builder.eval_gemfile(gemfile, &prepare_block) if prepare_block
builder.eval_gemfile(gemfile)
definition = builder.to_definition(lockfile, {})
definition.instance_variable_set(:@dependency_changes, dependency_changes) if dependency_changes
orig_definition = definition.dup # we might need it twice
current_lockfile = lockfile_definition[:lockfile]
if current_lockfile.exist?
definition.instance_variable_set(:@lockfile_contents, current_lockfile.read)
if install
current_definition = builder.to_definition(current_lockfile, {})
begin
current_definition.resolve_only_locally!
if current_definition.missing_specs.any?
Bundler.with_default_lockfile(current_lockfile) do
Installer.install(gemfile.dirname, current_definition, {})
end
end
rescue RubyVersionMismatch, GemNotFound, SolveFailure
# ignore
end
end
end
resolved_remotely = false
begin
previous_ui_level = Bundler.ui.level
Bundler.ui.level = "warn"
begin
definition.resolve_with_cache!
rescue GemNotFound, SolveFailure
definition = orig_definition
definition.resolve_remotely!
resolved_remotely = true
end
definition.lock(lockfile_definition[:lockfile], true)
ensure
Bundler.ui.level = previous_ui_level
end
# if we're running `bundle install` or `bundle update`, and something is missing from
# the secondary lockfile, install it.
if install && (definition.missing_specs.any? || resolved_remotely)
Bundler.with_default_lockfile(lockfile_definition[:lockfile]) do
Installer.install(lockfile_definition[:gemfile].dirname, definition, {})
end
end
!definition.nothing_changed?
end
end
reset!
@recursive = false
@prepare_block = nil
end
end

View File

@ -0,0 +1,207 @@
# frozen_string_literal: true
require "set"
module Bundler
module Multilock
class Check
class << self
def run
new.run
end
end
def initialize
default_lockfile_contents = Bundler.default_lockfile.read
@default_lockfile = LockfileParser.new(default_lockfile_contents)
@default_specs = @default_lockfile.specs.to_h do |spec|
[[spec.name, spec.platform], spec]
end
end
def run(skip_base_checks: false)
return true unless Bundler.default_lockfile.exist?
success = true
unless skip_base_checks
missing_specs = base_check({ gemfile: Bundler.default_gemfile, lockfile: Bundler.default_lockfile },
return_missing: true).to_set
end
Multilock.lockfile_definitions.each do |lockfile_definition|
next if lockfile_definition[:lockfile] == Bundler.default_lockfile
unless lockfile_definition[:lockfile].exist?
Bundler.ui.error("Lockfile #{lockfile_definition[:lockfile]} does not exist.")
success = false
end
unless skip_base_checks
new_missing = base_check(lockfile_definition, log_missing: missing_specs, return_missing: true)
success = false unless new_missing.empty?
missing_specs.merge(new_missing)
end
success = false unless check(lockfile_definition)
end
success
end
# this is mostly equivalent to the built in checks in `bundle check`, but even
# more conservative, and returns false instead of exiting on failure
def base_check(lockfile_definition, log_missing: false, return_missing: false)
return return_missing ? [] : false unless lockfile_definition[:lockfile].file?
Multilock.prepare_block = lockfile_definition[:prepare]
definition = Definition.build(lockfile_definition[:gemfile], lockfile_definition[:lockfile], false)
return return_missing ? [] : false unless definition.send(:current_platform_locked?)
begin
definition.validate_runtime!
Bundler.ui.silence do
definition.resolve_only_locally!
end
not_installed = definition.missing_specs
rescue RubyVersionMismatch, GemNotFound, SolveFailure
return return_missing ? [] : false
end
if log_missing
not_installed.each do |spec|
next if log_missing.include?(spec)
Bundler.ui.error "The following gems are missing" if log_missing.empty?
Bundler.ui.error(" * #{spec.name} (#{spec.version})")
end
end
return not_installed if return_missing
not_installed.empty? && definition.no_resolve_needed?
ensure
Multilock.prepare_block = nil
end
# this checks for mismatches between the default lockfile and the given lockfile,
# and for pinned dependencies in lockfiles requiring them
def check(lockfile_definition, allow_mismatched_dependencies: true)
success = true
proven_pinned = Set.new
needs_pin_check = []
lockfile = LockfileParser.new(lockfile_definition[:lockfile].read)
lockfile_path = lockfile_definition[:lockfile].relative_path_from(Dir.pwd)
unless lockfile.platforms == @default_lockfile.platforms
Bundler.ui.error("The platforms in #{lockfile_path} do not match the default lockfile.")
success = false
end
unless lockfile.bundler_version == @default_lockfile.bundler_version
Bundler.ui.error("bundler (#{lockfile.bundler_version}) in #{lockfile_path} " \
"does not match the default lockfile's version (@#{@default_lockfile.bundler_version}).")
success = false
end
specs = lockfile.specs.group_by(&:name)
if allow_mismatched_dependencies
allow_mismatched_dependencies = lockfile_definition[:allow_mismatched_dependencies]
end
# build list of top-level dependencies that differ from the default lockfile,
# and all _their_ transitive dependencies
if allow_mismatched_dependencies
transitive_dependencies = Set.new
# only dependencies that differ from the default lockfile
pending_transitive_dependencies = lockfile.dependencies.reject do |name, dep|
@default_lockfile.dependencies[name] == dep
end.map(&:first)
until pending_transitive_dependencies.empty?
dep = pending_transitive_dependencies.shift
next if transitive_dependencies.include?(dep)
transitive_dependencies << dep
platform_specs = specs[dep]
unless platform_specs
# should only be bundler that's missing a spec
raise "Could not find spec for dependency #{dep}" unless dep == "bundler"
next
end
pending_transitive_dependencies.concat(platform_specs.flat_map(&:dependencies).map(&:name).uniq)
end
end
# look through top-level explicit dependencies for pinned requirements
if lockfile_definition[:enforce_pinned_additional_dependencies]
find_pinned_dependencies(proven_pinned, lockfile.dependencies.each_value)
end
# check for conflicting requirements (and build list of pins, in the same loop)
specs.values.flatten.each do |spec|
default_spec = @default_specs[[spec.name, spec.platform]]
if lockfile_definition[:enforce_pinned_additional_dependencies]
# look through what this spec depends on, and keep track of all pinned requirements
find_pinned_dependencies(proven_pinned, spec.dependencies)
needs_pin_check << spec unless default_spec
end
next unless default_spec
# have to ensure Path sources are relative to their lockfile before comparing
same_source = if [default_spec.source, spec.source].grep(Source::Path).length == 2
lockfile_definition[:lockfile]
.dirname
.join(spec.source.path)
.ascend
.any?(Bundler.default_lockfile.dirname.join(default_spec.source.path))
else
default_spec.source == spec.source
end
next if default_spec.version == spec.version && same_source
next if allow_mismatched_dependencies && transitive_dependencies.include?(spec.name)
Bundler.ui.error("#{spec}#{spec.git_version} in #{lockfile_path} " \
"does not match the default lockfile's version " \
"(@#{default_spec.version}#{default_spec.git_version}); " \
"this may be due to a conflicting requirement, which would require manual resolution.")
success = false
end
# now that we have built a list of every gem that is pinned, go through
# the gems that were in this lockfile, but not the default lockfile, and
# ensure it's pinned _somehow_
needs_pin_check.each do |spec|
pinned = case spec.source
when Source::Git
spec.source.ref == spec.source.revision
when Source::Path
true
when Source::Rubygems
proven_pinned.include?(spec.name)
else
false
end
next if pinned
Bundler.ui.error("#{spec} in #{lockfile_path} has not been pinned to a specific version, " \
"which is required since it is not part of the default lockfile.")
success = false
end
success
end
private
def find_pinned_dependencies(proven_pinned, dependencies)
dependencies.each do |dependency|
dependency.requirement.requirements.each do |requirement|
proven_pinned << dependency.name if requirement.first == "="
end
end
end
end
end
end

View File

@ -0,0 +1,39 @@
# frozen_string_literal: true
module Bundler
module Multilock
module Ext
module BundlerClassMethods
def self.prepended(klass)
super
klass.attr_writer :cache_root, :default_lockfile, :root
end
::Bundler.singleton_class.prepend(self)
def app_cache(custom_path = nil)
super(custom_path || @cache_root)
end
def default_lockfile(force_original: false)
return @default_lockfile if @default_lockfile && !force_original
super()
end
def with_default_lockfile(lockfile)
previous_default_lockfile, @default_lockfile = @default_lockfile, lockfile
yield
ensure
@default_lockfile = previous_default_lockfile
end
def reset!
super
Multilock.reset!
end
end
end
end
end

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
module Bundler
module Multilock
module Ext
module Definition
::Bundler::Definition.prepend(self)
def initialize(lockfile, *args)
# we changed the default lockfile in Bundler::Multilock.add_lockfile
# since DSL.evaluate was called (re-entrantly); sub the proper value in
if !lockfile.equal?(Bundler.default_lockfile) &&
Bundler.default_lockfile(force_original: true) == lockfile
lockfile = Bundler.default_lockfile
end
super
end
def validate_runtime!
Multilock.loaded! unless Multilock.lockfile_definitions.empty?
super
end
end
end
end
end

View File

@ -0,0 +1,64 @@
# frozen_string_literal: true
require "set"
module Bundler
module Multilock
module Ext
module Dsl
module ClassMethods
::Bundler::Dsl.singleton_class.prepend(self)
# Significant changes:
# * evaluate the prepare block as part of the gemfile
# * mark Multilock as loaded once the main gemfile is evaluated
# so that they're not loaded multiple times
def evaluate(gemfile, lockfile, unlock)
builder = new
builder.eval_gemfile(gemfile, &Multilock.prepare_block) if Multilock.prepare_block
builder.eval_gemfile(gemfile)
Multilock.loaded!
builder.to_definition(lockfile, unlock)
end
end
::Bundler::Dsl.prepend(self)
def initialize
super
@gemfiles = Set.new
Multilock.loaded! unless Multilock.lockfile_definitions.empty?
end
# Significant changes:
# * allow a block
def eval_gemfile(gemfile, contents = nil, &block)
expanded_gemfile_path = Pathname.new(gemfile).expand_path(@gemfile&.parent)
original_gemfile = @gemfile
@gemfile = expanded_gemfile_path
@gemfiles << expanded_gemfile_path
contents ||= Bundler.read_file(@gemfile.to_s)
if block
instance_eval(&block)
else
instance_eval(contents.dup.tap { |x| x.untaint if RUBY_VERSION < "2.7" }, gemfile.to_s, 1)
end
rescue Exception => e # rubocop:disable Lint/RescueException
message = "There was an error " \
"#{e.is_a?(GemfileEvalError) ? "evaluating" : "parsing"} " \
"`#{File.basename gemfile.to_s}`: #{e.message}"
raise Bundler::Dsl::DSLError.new(message, gemfile, e.backtrace, contents)
ensure
@gemfile = original_gemfile
end
def lockfile(*args, **kwargs, &block)
return true if Multilock.loaded?
Multilock.add_lockfile(*args, builder: self, **kwargs, &block)
end
end
end
end
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
module Bundler
module Multilock
module Ext
module PluginExt
module ClassMethods
::Bundler::Plugin.singleton_class.prepend(self)
def load_plugin(name)
return if @loaded_plugin_names.include?(name)
super
end
end
end
end
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
module Bundler
module Multilock
module Ext
module PluginExt
module DSL
::Bundler::Plugin::DSL.include(self)
def lockfile(...)
# pass
end
end
end
end
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
module Bundler
module Multilock
module Ext
module SourceList
::Bundler::SourceList.prepend(self)
# consider them equivalent if the replacements just have a bunch of dups
def equivalent_sources?(lock_sources, replacement_sources)
super(lock_sources, replacement_sources.uniq)
end
end
end
end
end

View File

@ -0,0 +1,44 @@
# frozen_string_literal: true
require "bundler/lockfile_generator"
module Bundler
module Multilock
# generates a lockfile based on another LockfileParser
class LockfileGenerator < Bundler::LockfileGenerator
def self.generate(lockfile)
new(LockfileAdapter.new(lockfile)).generate!
end
private
class LockfileAdapter < SimpleDelegator
def sources
self
end
def lock_sources
__getobj__.sources
end
def resolve
specs
end
def dependencies
super.values
end
def locked_ruby_version
ruby_version
end
end
private_constant :LockfileAdapter
def add_bundled_with
add_section("BUNDLED WITH", definition.bundler_version.to_s)
end
end
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
module Bundler
module Multilock
VERSION = "1.0.7"
end
end

View File

@ -18,8 +18,33 @@
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require "bundler_lockfile_extensions"
require_relative "lib/bundler/multilock"
# this is terrible, but we can't prepend into these modules because we only load
# _inside_ of the CLI commands already running
if defined?(Bundler::CLI::Check)
require_relative "lib/bundler/multilock/check"
at_exit do
next unless $!.nil?
next if $!.is_a?(SystemExit) && !$!.success?
next if Bundler::Multilock::Check.run
Bundler.ui.warn("You can attempt to fix by running `bundle install`")
exit 1
end
end
if defined?(Bundler::CLI::Lock)
at_exit do
next unless $!.nil?
next if $!.is_a?(SystemExit) && !$!.success?
Bundler::Multilock.after_install_all(install: false)
end
end
Bundler::Plugin.add_hook(Bundler::Plugin::Events::GEM_AFTER_INSTALL_ALL) do |_|
BundlerLockfileExtensions.after_install_all
Bundler::Multilock.after_install_all
end
Bundler::Multilock.inject_preamble unless Bundler::Multilock.loaded?