diff --git a/.github/workflows/rubygems.yml b/.github/workflows/rubygems.yml index f37377877d3e..875a47c4971e 100644 --- a/.github/workflows/rubygems.yml +++ b/.github/workflows/rubygems.yml @@ -39,7 +39,14 @@ jobs: - ruby: { name: truffleruby, value: truffleruby-24.2.1 } os: { name: Ubuntu, value: ubuntu-24.04 } + - ruby: { name: no symlinks, value: 4.0.0 } + os: { name: Windows, value: windows-2025 } + symlink: off + steps: + - name: disable development mode on Windows + run: powershell -c "Set-ItemProperty -Path HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock -Name AllowDevelopmentWithoutDevLicense -Value 0" + if: matrix.symlink == 'off' - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: persist-credentials: false @@ -52,7 +59,7 @@ jobs: run: bin/rake setup - name: Run Test run: bin/rake test - if: matrix.ruby.name != 'truffleruby' && matrix.ruby.name != 'jruby' + if: matrix.ruby.name != 'truffleruby' && matrix.ruby.name != 'jruby' && matrix.symlink != 'off' - name: Run Test isolatedly run: bin/rake test:isolated if: matrix.ruby.name == '3.4' && matrix.os.name != 'Windows' @@ -62,6 +69,11 @@ jobs: - name: Run Test (Truffleruby) run: TRUFFLERUBYOPT="--experimental-options --testing-rubygems" bin/rake test if: matrix.ruby.name == 'truffleruby' + - name: Run Test with non-Admin user + run: | + gem inst win32-process --no-doc --conservative + ruby bin/windows_run_as_user ruby -S rake test + if: matrix.symlink == 'off' timeout-minutes: 60 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d28ecb8896c..d06180d06802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 4.0.13 / 2026-06-03 + +### Enhancements: + +* Prevent extraction from escaping destination_dir via pre-existing symlinks. Pull request [#9493](https://github.com/ruby/rubygems/pull/9493) by thesmartshadow +* Close stdin immediately when using popen2e. Pull request [#9540](https://github.com/ruby/rubygems/pull/9540) by rwstauner +* Fallback to copy symlinks on Windows. Pull request [#9296](https://github.com/ruby/rubygems/pull/9296) by larskanis +* Installs bundler 4.0.13 as a default gem. + ## 4.0.12 / 2026-05-20 ### Enhancements: diff --git a/bin/windows_run_as_user b/bin/windows_run_as_user new file mode 100644 index 000000000000..358e91f680de --- /dev/null +++ b/bin/windows_run_as_user @@ -0,0 +1,36 @@ +require "win32/process" +require "rbconfig" + +testuser = "testuser" +testpassword = "Password123+" + +# Remove a previous test user if present +# See https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/net-user +system("net user #{testuser} /del 2>NUL") +# Create a new non-admin user +system("net user #{testuser} \"#{testpassword}\" /add") + +pinfo = nil +IO.pipe do |stdout_read, stdout_write| + cmd = ARGV.join(" ") + env = { + "TMP" => "#{Dir.pwd}/tmp", + "TEMP" => "#{Dir.pwd}/tmp" + } + pinfo = Process.create command_line: cmd, + with_logon: testuser, + password: testpassword, + cwd: Dir.pwd, + environment: ENV.to_h.merge(env).map{|k,v| "#{k}=#{v}" }, + startup_info: { stdout: stdout_write, stderr: stdout_write } + + stdout_write.close + stdout_read.each_line do |line| + puts(line) + end +end + +# Wait for process to terminate +sleep 1 while !(ecode=Process.get_exitcode(pinfo.process_id)) + +exit ecode diff --git a/bundler/CHANGELOG.md b/bundler/CHANGELOG.md index 78b630a1254a..4afaaad60e00 100644 --- a/bundler/CHANGELOG.md +++ b/bundler/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 4.0.13 / 2026-06-03 + +### Enhancements: + +* Do not hard-code permissions for new gem directories during bundle install. Pull request [#9557](https://github.com/ruby/rubygems/pull/9557) by maxfelsher-cgi +* Clear gem specification cache after acquiring process lock. Pull request [#9310](https://github.com/ruby/rubygems/pull/9310) by ngan +* Show release date with bundle outdated. Pull request [#9337](https://github.com/ruby/rubygems/pull/9337) by hsbt + +### Bug fixes: + +* Apply cooldown to locally installed gem versions. Pull request [#9582](https://github.com/ruby/rubygems/pull/9582) by hsbt + +### Security: + +* Add `cooldown` to delay newly published gem. Pull request [#9576](https://github.com/ruby/rubygems/pull/9576) by hsbt + ## 4.0.12 / 2026-05-20 ### Enhancements: diff --git a/bundler/lib/bundler/cli.rb b/bundler/lib/bundler/cli.rb index 7a04f3da2390..11fdd91a073c 100644 --- a/bundler/lib/bundler/cli.rb +++ b/bundler/lib/bundler/cli.rb @@ -274,6 +274,7 @@ def remove(*gems) method_option "target-rbconfig", type: :string, banner: "Path to rbconfig.rb for the deployment target platform" method_option "without", type: :array, banner: "Exclude gems that are part of the specified named group (removed)." method_option "with", type: :array, banner: "Include gems that are part of the specified named group (removed)." + method_option "cooldown", type: :numeric, banner: "Only consider gem versions published at least N days ago. Use 0 to disable." def install %w[clean deployment frozen no-prune path shebang without with].each do |option| remembered_flag_deprecation(option) @@ -324,6 +325,7 @@ def install method_option "strict", type: :boolean, banner: "Do not allow any gem to be updated past latest --patch | --minor | --major" method_option "conservative", type: :boolean, banner: "Use bundle install conservative update behavior and do not allow shared dependencies to be updated." method_option "all", type: :boolean, banner: "Update everything." + method_option "cooldown", type: :numeric, banner: "Only consider gem versions published at least N days ago. Use 0 to disable." def update(*gems) require_relative "cli/update" Bundler.settings.temporary(no_install: false) do @@ -405,6 +407,7 @@ def binstubs(*gems) method_option "skip-install", type: :boolean, banner: "Adds gem to the Gemfile but does not install it" method_option "optimistic", type: :boolean, banner: "Adds optimistic declaration of version to gem" method_option "strict", type: :boolean, banner: "Adds strict declaration of version to gem" + method_option "cooldown", type: :numeric, banner: "Only consider gem versions published at least N days ago. Use 0 to disable." def add(*gems) require_relative "cli/add" Add.new(options.dup, gems).run @@ -435,6 +438,7 @@ def add(*gems) method_option "filter-patch", type: :boolean, banner: "Only list patch newer versions" method_option "parseable", aliases: "--porcelain", type: :boolean, banner: "Use minimal formatting for more parseable output" method_option "only-explicit", type: :boolean, banner: "Only list gems specified in your Gemfile, not their dependencies" + method_option "cooldown", type: :numeric, banner: "Only consider gem versions published at least N days ago. Use 0 to disable." def outdated(*gems) require_relative "cli/outdated" Outdated.new(options, gems).run diff --git a/bundler/lib/bundler/cli/add.rb b/bundler/lib/bundler/cli/add.rb index 9f1760409617..ad76b019479e 100644 --- a/bundler/lib/bundler/cli/add.rb +++ b/bundler/lib/bundler/cli/add.rb @@ -14,6 +14,9 @@ def initialize(options, gems) def run Bundler.ui.level = "warn" if options[:quiet] + Bundler::CLI::Common.validate_cooldown!(options[:cooldown]) + Bundler.settings.set_command_option_if_given :cooldown, options[:cooldown] + validate_options! inject_dependencies perform_bundle_install unless options["skip-install"] diff --git a/bundler/lib/bundler/cli/common.rb b/bundler/lib/bundler/cli/common.rb index 2f332ff36440..b44fbc309641 100644 --- a/bundler/lib/bundler/cli/common.rb +++ b/bundler/lib/bundler/cli/common.rb @@ -2,6 +2,12 @@ module Bundler module CLI::Common + def self.validate_cooldown!(value) + return if value.nil? + return if value.is_a?(Integer) && value >= 0 + raise InvalidOption, "Expected `--cooldown` to be a non-negative integer, got #{value.inspect}" + end + def self.output_post_install_messages(messages) return if Bundler.settings["ignore_messages"] messages.to_a.each do |name, msg| diff --git a/bundler/lib/bundler/cli/install.rb b/bundler/lib/bundler/cli/install.rb index 67feba84bdb0..69affd1a109a 100644 --- a/bundler/lib/bundler/cli/install.rb +++ b/bundler/lib/bundler/cli/install.rb @@ -112,6 +112,9 @@ def normalize_settings Bundler.settings.set_command_option_if_given :jobs, options["jobs"] + Bundler::CLI::Common.validate_cooldown!(options["cooldown"]) + Bundler.settings.set_command_option_if_given :cooldown, options["cooldown"] + Bundler.settings.set_command_option_if_given :no_prune, options["no-prune"] Bundler.settings.set_command_option_if_given :no_install, options["no-install"] diff --git a/bundler/lib/bundler/cli/outdated.rb b/bundler/lib/bundler/cli/outdated.rb index 0c8ba3ebf730..465e56ada2cc 100644 --- a/bundler/lib/bundler/cli/outdated.rb +++ b/bundler/lib/bundler/cli/outdated.rb @@ -26,6 +26,9 @@ def initialize(options, gems) def run check_for_deployment_mode! + Bundler::CLI::Common.validate_cooldown!(options[:cooldown]) + Bundler.settings.set_command_option_if_given :cooldown, options[:cooldown] + Bundler.definition.validate_runtime! current_specs = Bundler.ui.silence { Bundler.definition.resolve } @@ -199,7 +202,15 @@ def print_gem(current_spec, active_spec, dependency, groups) end spec_outdated_info = "#{active_spec.name} (newest #{spec_version}, " \ - "installed #{current_version}#{dependency_version})" + "installed #{current_version}#{dependency_version}" + + release_date = release_date_for(active_spec) + spec_outdated_info += ", released #{release_date}" unless release_date.empty? + + remaining = cooldown_days_remaining(active_spec) + spec_outdated_info += ", in cooldown for #{remaining} more day#{"s" if remaining > 1}" if remaining + + spec_outdated_info += ")" output_message = if options[:parseable] spec_outdated_info.to_s @@ -215,13 +226,25 @@ def print_gem(current_spec, active_spec, dependency, groups) def gem_column_for(current_spec, active_spec, dependency, groups) current_version = "#{current_spec.version}#{current_spec.git_version}" spec_version = "#{active_spec.version}#{active_spec.git_version}" + remaining = cooldown_days_remaining(active_spec) + spec_version += " (cooldown #{remaining}d)" if remaining dependency = dependency.requirement if dependency ret_val = [active_spec.name, current_version, spec_version, dependency.to_s, groups.to_s] + ret_val << release_date_for(active_spec) ret_val << loaded_from_for(active_spec).to_s if Bundler.ui.debug? ret_val end + def cooldown_days_remaining(spec, now = Time.now) + return nil unless spec.respond_to?(:created_at) && spec.created_at + return nil unless spec.respond_to?(:remote) && spec.remote + days = spec.remote.effective_cooldown + return nil if days.nil? || days <= 0 + remaining = days - ((now - spec.created_at) / 86_400.0) + remaining > 0 ? remaining.ceil : nil + end + def check_for_deployment_mode! return unless Bundler.frozen_bundle? suggested_command = if Bundler.settings.locations("frozen").keys.&([:global, :local]).any? @@ -283,11 +306,28 @@ def print_indented(matrix) end def table_header - header = ["Gem", "Current", "Latest", "Requested", "Groups"] + header = ["Gem", "Current", "Latest", "Requested", "Groups", "Release Date"] header << "Path" if Bundler.ui.debug? header end + def release_date_for(spec) + return "" unless spec.respond_to?(:date) + + date = spec.date + return "" unless date + + return "" unless Gem.const_defined?(:DEFAULT_SOURCE_DATE_EPOCH) + default_date = Time.at(Gem::DEFAULT_SOURCE_DATE_EPOCH).utc + default_date = Time.utc(default_date.year, default_date.month, default_date.day) + + date = date.utc if date.respond_to?(:utc) + + return "" if date == default_date + + date.strftime("%Y-%m-%d") + end + def justify(row, sizes) row.each_with_index.map do |element, index| element.ljust(sizes[index]) diff --git a/bundler/lib/bundler/cli/update.rb b/bundler/lib/bundler/cli/update.rb index 9cc90acc5853..d92ffd995f36 100644 --- a/bundler/lib/bundler/cli/update.rb +++ b/bundler/lib/bundler/cli/update.rb @@ -66,6 +66,8 @@ def run opts["force"] = options[:redownload] if options[:redownload] Bundler.settings.set_command_option_if_given :jobs, opts["jobs"] + Bundler::CLI::Common.validate_cooldown!(options[:cooldown]) + Bundler.settings.set_command_option_if_given :cooldown, options[:cooldown] Bundler.definition.validate_runtime! diff --git a/bundler/lib/bundler/dsl.rb b/bundler/lib/bundler/dsl.rb index 6f06c4e91879..bd934cc9aed6 100644 --- a/bundler/lib/bundler/dsl.rb +++ b/bundler/lib/bundler/dsl.rb @@ -116,6 +116,10 @@ def source(source, *args, &blk) options = args.last.is_a?(Hash) ? args.pop.dup : {} options = normalize_hash(options) source = normalize_source(source) + cooldown = options["cooldown"] + if cooldown && !(cooldown.is_a?(Integer) && cooldown >= 0) + raise InvalidOption, "Expected `cooldown` to be a non-negative integer, got #{cooldown.inspect}" + end if options.key?("type") options["type"] = options["type"].to_s @@ -130,9 +134,9 @@ def source(source, *args, &blk) source_opts = options.merge("uri" => source) with_source(@sources.add_plugin_source(options["type"], source_opts), &blk) elsif block_given? - with_source(@sources.add_rubygems_source("remotes" => source), &blk) + with_source(@sources.add_rubygems_source("remotes" => source, "cooldown" => cooldown), &blk) else - @sources.add_global_rubygems_remote(source) + @sources.add_global_rubygems_remote(source, cooldown: cooldown) end end diff --git a/bundler/lib/bundler/endpoint_specification.rb b/bundler/lib/bundler/endpoint_specification.rb index c06684657d59..7c7ce107e205 100644 --- a/bundler/lib/bundler/endpoint_specification.rb +++ b/bundler/lib/bundler/endpoint_specification.rb @@ -5,7 +5,7 @@ module Bundler class EndpointSpecification < Gem::Specification include MatchRemoteMetadata - attr_reader :name, :version, :platform, :checksum + attr_reader :name, :version, :platform, :checksum, :created_at attr_writer :dependencies attr_accessor :remote, :locked_platform @@ -145,6 +145,7 @@ def parse_metadata(data) unless data @required_ruby_version = nil @required_rubygems_version = nil + @created_at = nil return end @@ -161,6 +162,15 @@ def parse_metadata(data) @required_rubygems_version = Gem::Requirement.new(v) when "ruby" @required_ruby_version = Gem::Requirement.new(v) + when "created_at" + value = v.is_a?(Array) ? v.last : v + if value.is_a?(String) + @created_at = begin + Time.new(value) + rescue ArgumentError + nil + end + end end end rescue StandardError => e diff --git a/bundler/lib/bundler/installer.rb b/bundler/lib/bundler/installer.rb index 3455f72c2143..763223ac008a 100644 --- a/bundler/lib/bundler/installer.rb +++ b/bundler/lib/bundler/installer.rb @@ -63,6 +63,11 @@ def run(options) Bundler.create_bundle_path ProcessLock.lock do + # Invalidate any stale gem specification cache from before we acquired the lock. + # Another process may have installed gems while we were waiting. + Gem::Specification.reset + @definition.sources.clear_cache + @definition.ensure_equivalent_gemfile_and_lockfile(options[:deployment]) if @definition.dependencies.empty? diff --git a/bundler/lib/bundler/man/bundle-add.1 b/bundler/lib/bundler/man/bundle-add.1 index f49d8e568c9b..c0813efdbd36 100644 --- a/bundler/lib/bundler/man/bundle-add.1 +++ b/bundler/lib/bundler/man/bundle-add.1 @@ -4,7 +4,7 @@ .SH "NAME" \fBbundle\-add\fR \- Add gem to the Gemfile and run bundle install .SH "SYNOPSIS" -\fBbundle add\fR \fIGEM_NAME\fR [\-\-group=GROUP] [\-\-version=VERSION] [\-\-source=SOURCE] [\-\-path=PATH] [\-\-git=GIT|\-\-github=GITHUB] [\-\-branch=BRANCH] [\-\-ref=REF] [\-\-quiet] [\-\-skip\-install] [\-\-strict|\-\-optimistic] +\fBbundle add\fR \fIGEM_NAME\fR [\-\-group=GROUP] [\-\-version=VERSION] [\-\-source=SOURCE] [\-\-path=PATH] [\-\-git=GIT|\-\-github=GITHUB] [\-\-branch=BRANCH] [\-\-ref=REF] [\-\-cooldown=NUMBER] [\-\-quiet] [\-\-skip\-install] [\-\-strict|\-\-optimistic] .SH "DESCRIPTION" Adds the named gem to the [\fBGemfile(5)\fR][Gemfile(5)] and run \fBbundle install\fR\. \fBbundle install\fR can be avoided by using the flag \fB\-\-skip\-install\fR\. .SH "OPTIONS" @@ -50,6 +50,9 @@ Adds optimistic declaration of version\. .TP \fB\-\-strict\fR Adds strict declaration of version\. +.TP +\fB\-\-cooldown=\fR +Only consider gem versions published at least \fInumber\fR days ago when resolving\. Pass \fB0\fR to disable cooldown for this run\. See \fBcooldown\fR in bundle\-config(1) for precedence rules\. .SH "EXAMPLES" .IP "1." 4 You can add the \fBrails\fR gem to the Gemfile without any version restriction\. The source of the gem will be the global source\. diff --git a/bundler/lib/bundler/man/bundle-add.1.ronn b/bundler/lib/bundler/man/bundle-add.1.ronn index 48c0c66b0946..4d8cd6e75eb8 100644 --- a/bundler/lib/bundler/man/bundle-add.1.ronn +++ b/bundler/lib/bundler/man/bundle-add.1.ronn @@ -5,7 +5,7 @@ bundle-add(1) -- Add gem to the Gemfile and run bundle install `bundle add` [--group=GROUP] [--version=VERSION] [--source=SOURCE] [--path=PATH] [--git=GIT|--github=GITHUB] [--branch=BRANCH] [--ref=REF] - [--quiet] [--skip-install] [--strict|--optimistic] + [--cooldown=NUMBER] [--quiet] [--skip-install] [--strict|--optimistic] ## DESCRIPTION @@ -56,6 +56,11 @@ Adds the named gem to the [`Gemfile(5)`][Gemfile(5)] and run `bundle install`. * `--strict`: Adds strict declaration of version. +* `--cooldown=`: + Only consider gem versions published at least days ago when + resolving. Pass `0` to disable cooldown for this run. See `cooldown` + in bundle-config(1) for precedence rules. + ## EXAMPLES 1. You can add the `rails` gem to the Gemfile without any version restriction. diff --git a/bundler/lib/bundler/man/bundle-config.1 b/bundler/lib/bundler/man/bundle-config.1 index b6df2ce77be0..e6e2fb81ade8 100644 --- a/bundler/lib/bundler/man/bundle-config.1 +++ b/bundler/lib/bundler/man/bundle-config.1 @@ -69,162 +69,126 @@ The canonical form of this configuration is \fB"without"\fR\. To convert the can Any periods in the configuration keys must be replaced with two underscores when setting it via environment variables\. The configuration key \fBlocal\.rack\fR becomes the environment variable \fBBUNDLE_LOCAL__RACK\fR\. .SH "LIST OF AVAILABLE KEYS" The following is a list of all configuration keys and their purpose\. You can learn more about their operation in bundle install(1) \fIbundle\-install\.1\.html\fR\. -.TP -\fBapi_request_size\fR (\fBBUNDLE_API_REQUEST_SIZE\fR) -Configure how many dependencies to fetch when resolving the specifications\. This configuration is only used when fetchig specifications from RubyGems servers that didn't implement the Compact Index API\. Defaults to 100\. -.TP -\fBauto_install\fR (\fBBUNDLE_AUTO_INSTALL\fR) -Automatically run \fBbundle install\fR when gems are missing\. -.TP -\fBbin\fR (\fBBUNDLE_BIN\fR) -If configured, \fBbundle binstubs\fR will install executables from gems in the bundle to the specified directory\. Otherwise it will create them in a \fBbin\fR directory relative to the Gemfile directory\. These executables run in Bundler's context\. If used, you might add this directory to your environment's \fBPATH\fR variable\. For instance, if the \fBrails\fR gem comes with a \fBrails\fR executable, \fBbundle binstubs\fR will create a \fBbin/rails\fR executable that ensures that all referred dependencies will be resolved using the bundled gems\. -.TP -\fBcache_all\fR (\fBBUNDLE_CACHE_ALL\fR) -Cache all gems, including path and git gems\. This needs to be explicitly before bundler 4, but will be the default on bundler 4\. -.TP -\fBcache_all_platforms\fR (\fBBUNDLE_CACHE_ALL_PLATFORMS\fR) -Cache gems for all platforms\. -.TP -\fBcache_path\fR (\fBBUNDLE_CACHE_PATH\fR) -The directory that bundler will place cached gems in when running \fBbundle package\fR, and that bundler will look in when installing gems\. Defaults to \fBvendor/cache\fR\. -.TP -\fBclean\fR (\fBBUNDLE_CLEAN\fR) -Whether Bundler should run \fBbundle clean\fR automatically after \fBbundle install\fR\. Defaults to \fBtrue\fR in Bundler 4, as long as \fBpath\fR is not explicitly configured\. -.TP -\fBconsole\fR (\fBBUNDLE_CONSOLE\fR) -The console that \fBbundle console\fR starts\. Defaults to \fBirb\fR\. -.TP -\fBdefault_cli_command\fR (\fBBUNDLE_DEFAULT_CLI_COMMAND\fR) -The command that running \fBbundle\fR without arguments should run\. Defaults to \fBcli_help\fR since Bundler 4, but can also be \fBinstall\fR which was the previous default\. -.TP -\fBdeployment\fR (\fBBUNDLE_DEPLOYMENT\fR) -Equivalent to setting \fBfrozen\fR to \fBtrue\fR and \fBpath\fR to \fBvendor/bundle\fR\. -.TP -\fBdisable_checksum_validation\fR (\fBBUNDLE_DISABLE_CHECKSUM_VALIDATION\fR) -Allow installing gems even if they do not match the checksum provided by RubyGems\. -.TP -\fBdisable_exec_load\fR (\fBBUNDLE_DISABLE_EXEC_LOAD\fR) -Stop Bundler from using \fBload\fR to launch an executable in\-process in \fBbundle exec\fR\. -.TP -\fBdisable_local_branch_check\fR (\fBBUNDLE_DISABLE_LOCAL_BRANCH_CHECK\fR) -Allow Bundler to use a local git override without a branch specified in the Gemfile\. -.TP -\fBdisable_local_revision_check\fR (\fBBUNDLE_DISABLE_LOCAL_REVISION_CHECK\fR) -Allow Bundler to use a local git override without checking if the revision present in the lockfile is present in the repository\. -.TP -\fBdisable_shared_gems\fR (\fBBUNDLE_DISABLE_SHARED_GEMS\fR) -Stop Bundler from accessing gems installed to RubyGems' normal location\. -.TP -\fBdisable_version_check\fR (\fBBUNDLE_DISABLE_VERSION_CHECK\fR) -Stop Bundler from checking if a newer Bundler version is available on rubygems\.org\. -.TP -\fBforce_ruby_platform\fR (\fBBUNDLE_FORCE_RUBY_PLATFORM\fR) -Ignore the current machine's platform and install only \fBruby\fR platform gems\. As a result, gems with native extensions will be compiled from source\. -.TP -\fBfrozen\fR (\fBBUNDLE_FROZEN\fR) -Disallow any automatic changes to \fBGemfile\.lock\fR\. Bundler commands will be blocked unless the lockfile can be installed exactly as written\. Usually this will happen when changing the \fBGemfile\fR manually and forgetting to update the lockfile through \fBbundle lock\fR or \fBbundle install\fR\. -.TP -\fBgem\.github_username\fR (\fBBUNDLE_GEM__GITHUB_USERNAME\fR) -Sets a GitHub username or organization to be used in the \fBREADME\fR and \fB\.gemspec\fR files when you create a new gem via \fBbundle gem\fR command\. It can be overridden by passing an explicit \fB\-\-github\-username\fR flag to \fBbundle gem\fR\. -.TP -\fBgem\.push_key\fR (\fBBUNDLE_GEM__PUSH_KEY\fR) -Sets the \fB\-\-key\fR parameter for \fBgem push\fR when using the \fBrake release\fR command with a private gemstash server\. -.TP -\fBgemfile\fR (\fBBUNDLE_GEMFILE\fR) -The name of the file that bundler should use as the \fBGemfile\fR\. This location of this file also sets the root of the project, which is used to resolve relative paths in the \fBGemfile\fR, among other things\. By default, bundler will search up from the current working directory until it finds a \fBGemfile\fR\. -.TP -\fBglobal_gem_cache\fR (\fBBUNDLE_GLOBAL_GEM_CACHE\fR) -Whether Bundler should cache all gems and compiled extensions globally, rather than locally to the configured installation path\. -.TP -\fBignore_funding_requests\fR (\fBBUNDLE_IGNORE_FUNDING_REQUESTS\fR) -When set, no funding requests will be printed\. -.TP -\fBignore_messages\fR (\fBBUNDLE_IGNORE_MESSAGES\fR) -When set, no post install messages will be printed\. To silence a single gem, use dot notation like \fBignore_messages\.httparty true\fR\. -.TP -\fBinit_gems_rb\fR (\fBBUNDLE_INIT_GEMS_RB\fR) -Generate a \fBgems\.rb\fR instead of a \fBGemfile\fR when running \fBbundle init\fR\. -.TP -\fBjobs\fR (\fBBUNDLE_JOBS\fR) -The number of gems Bundler can download and install in parallel\. Defaults to the number of available processors\. -.TP -\fBlockfile\fR (\fBBUNDLE_LOCKFILE\fR) -The path to the lockfile that bundler should use\. By default, Bundler adds \fB\.lock\fR to the end of the \fBgemfile\fR entry\. Can be set to \fBfalse\fR in the Gemfile to disable lockfile creation entirely (see gemfile(5))\. -.TP -\fBlockfile_checksums\fR (\fBBUNDLE_LOCKFILE_CHECKSUMS\fR) -Whether Bundler should include a checksums section in new lockfiles, to protect from compromised gem sources\. Defaults to true\. -.TP -\fBno_install\fR (\fBBUNDLE_NO_INSTALL\fR) -Whether \fBbundle package\fR should skip installing gems\. -.TP -\fBno_prune\fR (\fBBUNDLE_NO_PRUNE\fR) -Whether Bundler should leave outdated gems unpruned when caching\. -.TP -\fBonly\fR (\fBBUNDLE_ONLY\fR) -A space\-separated list of groups to install only gems of the specified groups\. Please check carefully if you want to install also gems without a group, because they get put inside \fBdefault\fR group\. For example \fBonly test:default\fR will install all gems specified in test group and without one\. -.TP -\fBpath\fR (\fBBUNDLE_PATH\fR) -The location on disk where all gems in your bundle will be located regardless of \fB$GEM_HOME\fR or \fB$GEM_PATH\fR values\. Bundle gems not found in this location will be installed by \fBbundle install\fR\. When not set, Bundler install by default to a \fB\.bundle\fR directory relative to repository root in Bundler 4, and to the default system path (\fBGem\.dir\fR) before Bundler 4\. That means that before Bundler 4, Bundler shares this location with Rubygems, and \fBgem install \|\.\|\.\|\.\fR will have gems installed in the same location and therefore, gems installed without \fBpath\fR set will show up by calling \fBgem list\fR\. This will not be the case in Bundler 4\. -.TP -\fBpath\.system\fR (\fBBUNDLE_PATH__SYSTEM\fR) -Whether Bundler will install gems into the default system path (\fBGem\.dir\fR)\. -.TP -\fBplugins\fR (\fBBUNDLE_PLUGINS\fR) -Enable Bundler's experimental plugin system\. -.TP -\fBprefer_patch\fR (\fBBUNDLE_PREFER_PATCH\fR) -Prefer updating only to next patch version during updates\. Makes \fBbundle update\fR calls equivalent to \fBbundler update \-\-patch\fR\. -.TP -\fBredirect\fR (\fBBUNDLE_REDIRECT\fR) -The number of redirects allowed for network requests\. Defaults to \fB5\fR\. -.TP -\fBretry\fR (\fBBUNDLE_RETRY\fR) -The number of times to retry failed network requests\. Defaults to \fB3\fR\. -.TP -\fBshebang\fR (\fBBUNDLE_SHEBANG\fR) -The program name that should be invoked for generated binstubs\. Defaults to the ruby install name used to generate the binstub\. -.TP -\fBsilence_deprecations\fR (\fBBUNDLE_SILENCE_DEPRECATIONS\fR) -Whether Bundler should silence deprecation warnings for behavior that will be changed in the next major version\. -.TP -\fBsilence_root_warning\fR (\fBBUNDLE_SILENCE_ROOT_WARNING\fR) -Silence the warning Bundler prints when installing gems as root\. -.TP -\fBsimulate_version\fR (\fBBUNDLE_SIMULATE_VERSION\fR) -The virtual version Bundler should use for activating feature flags\. Can be used to simulate all the new functionality that will be enabled in a future major version\. -.TP -\fBssl_ca_cert\fR (\fBBUNDLE_SSL_CA_CERT\fR) -Path to a designated CA certificate file or folder containing multiple certificates for trusted CAs in PEM format\. -.TP -\fBssl_client_cert\fR (\fBBUNDLE_SSL_CLIENT_CERT\fR) -Path to a designated file containing a X\.509 client certificate and key in PEM format\. -.TP -\fBssl_verify_mode\fR (\fBBUNDLE_SSL_VERIFY_MODE\fR) -The SSL verification mode Bundler uses when making HTTPS requests\. Defaults to verify peer\. -.TP -\fBsystem_bindir\fR (\fBBUNDLE_SYSTEM_BINDIR\fR) -The location where RubyGems installs binstubs\. Defaults to \fBGem\.bindir\fR\. -.TP -\fBtimeout\fR (\fBBUNDLE_TIMEOUT\fR) -The seconds allowed before timing out for network requests\. Defaults to \fB10\fR\. -.TP -\fBupdate_requires_all_flag\fR (\fBBUNDLE_UPDATE_REQUIRES_ALL_FLAG\fR) -Require passing \fB\-\-all\fR to \fBbundle update\fR when everything should be updated, and disallow passing no options to \fBbundle update\fR\. -.TP -\fBuser_agent\fR (\fBBUNDLE_USER_AGENT\fR) -The custom user agent fragment Bundler includes in API requests\. -.TP -\fBverbose\fR (\fBBUNDLE_VERBOSE\fR) -Whether Bundler should print verbose output\. Defaults to \fBfalse\fR, unless the \fB\-\-verbose\fR CLI flag is used\. -.TP -\fBversion\fR (\fBBUNDLE_VERSION\fR) -The version of Bundler to use when running under Bundler environment\. Defaults to \fBlockfile\fR\. You can also specify \fBsystem\fR or \fBx\.y\.z\fR\. \fBlockfile\fR will use the Bundler version specified in the \fBGemfile\.lock\fR, \fBsystem\fR will use the system version of Bundler, and \fBx\.y\.z\fR will use the specified version of Bundler\. -.TP -\fBwith\fR (\fBBUNDLE_WITH\fR) -A space\-separated or \fB:\fR\-separated list of groups whose gems bundler should install\. -.TP -\fBwithout\fR (\fBBUNDLE_WITHOUT\fR) -A space\-separated or \fB:\fR\-separated list of groups whose gems bundler should not install\. +.IP "\(bu" 4 +\fBapi_request_size\fR (\fBBUNDLE_API_REQUEST_SIZE\fR): Configure how many dependencies to fetch when resolving the specifications\. This configuration is only used when fetchig specifications from RubyGems servers that didn't implement the Compact Index API\. Defaults to 100\. +.IP "\(bu" 4 +\fBauto_install\fR (\fBBUNDLE_AUTO_INSTALL\fR): Automatically run \fBbundle install\fR when gems are missing\. +.IP "\(bu" 4 +\fBbin\fR (\fBBUNDLE_BIN\fR): If configured, \fBbundle binstubs\fR will install executables from gems in the bundle to the specified directory\. Otherwise it will create them in a \fBbin\fR directory relative to the Gemfile directory\. These executables run in Bundler's context\. If used, you might add this directory to your environment's \fBPATH\fR variable\. For instance, if the \fBrails\fR gem comes with a \fBrails\fR executable, \fBbundle binstubs\fR will create a \fBbin/rails\fR executable that ensures that all referred dependencies will be resolved using the bundled gems\. +.IP "\(bu" 4 +\fBcache_all\fR (\fBBUNDLE_CACHE_ALL\fR): Cache all gems, including path and git gems\. This needs to be explicitly before bundler 4, but will be the default on bundler 4\. +.IP "\(bu" 4 +\fBcache_all_platforms\fR (\fBBUNDLE_CACHE_ALL_PLATFORMS\fR): Cache gems for all platforms\. +.IP "\(bu" 4 +\fBcache_path\fR (\fBBUNDLE_CACHE_PATH\fR): The directory that bundler will place cached gems in when running \fBbundle package\fR, and that bundler will look in when installing gems\. Defaults to \fBvendor/cache\fR\. +.IP "\(bu" 4 +\fBclean\fR (\fBBUNDLE_CLEAN\fR): Whether Bundler should run \fBbundle clean\fR automatically after \fBbundle install\fR\. Defaults to \fBtrue\fR in Bundler 4, as long as \fBpath\fR is not explicitly configured\. +.IP "\(bu" 4 +\fBconsole\fR (\fBBUNDLE_CONSOLE\fR): The console that \fBbundle console\fR starts\. Defaults to \fBirb\fR\. +.IP "\(bu" 4 +\fBcooldown\fR (\fBBUNDLE_COOLDOWN\fR): Number of days a published gem version must age before bundler will resolve to it\. Defaults to unset (no cooldown)\. Pass \fB0\fR to disable cooldown for an individual run\. +.IP +The effective cooldown for any given gem is resolved from three layers, highest precedence first: +.IP "1." 4 +CLI flag \fB\-\-cooldown N\fR on \fBinstall\fR, \fBupdate\fR, \fBadd\fR, and \fBoutdated\fR\. +.IP "2." 4 +This setting (\fBbundle config set cooldown N\fR or \fBBUNDLE_COOLDOWN=N\fR)\. +.IP "3." 4 +The per\-source \fBcooldown:\fR keyword in the Gemfile, such as \fBsource "https://rubygems\.org", cooldown: 7\fR\. +.IP "" 0 +.IP +The CLI flag and this setting apply uniformly to every source, including ones declared with their own \fBcooldown:\fR value\. To keep a private registry permanently exempt while still cooling down public gems, declare \fBsource "https://internal", cooldown: 0\fR in the Gemfile; remember that \fB\-\-cooldown N\fR on the command line will still override it for that single run\. +.IP +Cooldown filtering depends on the gem server providing a per\-version \fBcreated_at\fR timestamp in the v2 compact\-index format\. Versions without that metadata \- older gem servers, historical entries that predate the v2 cutover on \fBrubygems\.org\fR, or private registries that still emit the v1 format \- are treated as outside the cooldown window and remain resolvable\. If you rely on cooldown for supply\-chain protection, confirm that the gem server emits \fBcreated_at\fR in its \fB/info/\fR responses\. +.IP "\(bu" 4 +\fBdefault_cli_command\fR (\fBBUNDLE_DEFAULT_CLI_COMMAND\fR): The command that running \fBbundle\fR without arguments should run\. Defaults to \fBcli_help\fR since Bundler 4, but can also be \fBinstall\fR which was the previous default\. +.IP "\(bu" 4 +\fBdeployment\fR (\fBBUNDLE_DEPLOYMENT\fR): Equivalent to setting \fBfrozen\fR to \fBtrue\fR and \fBpath\fR to \fBvendor/bundle\fR\. +.IP "\(bu" 4 +\fBdisable_checksum_validation\fR (\fBBUNDLE_DISABLE_CHECKSUM_VALIDATION\fR): Allow installing gems even if they do not match the checksum provided by RubyGems\. +.IP "\(bu" 4 +\fBdisable_exec_load\fR (\fBBUNDLE_DISABLE_EXEC_LOAD\fR): Stop Bundler from using \fBload\fR to launch an executable in\-process in \fBbundle exec\fR\. +.IP "\(bu" 4 +\fBdisable_local_branch_check\fR (\fBBUNDLE_DISABLE_LOCAL_BRANCH_CHECK\fR): Allow Bundler to use a local git override without a branch specified in the Gemfile\. +.IP "\(bu" 4 +\fBdisable_local_revision_check\fR (\fBBUNDLE_DISABLE_LOCAL_REVISION_CHECK\fR): Allow Bundler to use a local git override without checking if the revision present in the lockfile is present in the repository\. +.IP "\(bu" 4 +\fBdisable_shared_gems\fR (\fBBUNDLE_DISABLE_SHARED_GEMS\fR): Stop Bundler from accessing gems installed to RubyGems' normal location\. +.IP "\(bu" 4 +\fBdisable_version_check\fR (\fBBUNDLE_DISABLE_VERSION_CHECK\fR): Stop Bundler from checking if a newer Bundler version is available on rubygems\.org\. +.IP "\(bu" 4 +\fBforce_ruby_platform\fR (\fBBUNDLE_FORCE_RUBY_PLATFORM\fR): Ignore the current machine's platform and install only \fBruby\fR platform gems\. As a result, gems with native extensions will be compiled from source\. +.IP "\(bu" 4 +\fBfrozen\fR (\fBBUNDLE_FROZEN\fR): Disallow any automatic changes to \fBGemfile\.lock\fR\. Bundler commands will be blocked unless the lockfile can be installed exactly as written\. Usually this will happen when changing the \fBGemfile\fR manually and forgetting to update the lockfile through \fBbundle lock\fR or \fBbundle install\fR\. +.IP "\(bu" 4 +\fBgem\.github_username\fR (\fBBUNDLE_GEM__GITHUB_USERNAME\fR): Sets a GitHub username or organization to be used in the \fBREADME\fR and \fB\.gemspec\fR files when you create a new gem via \fBbundle gem\fR command\. It can be overridden by passing an explicit \fB\-\-github\-username\fR flag to \fBbundle gem\fR\. +.IP "\(bu" 4 +\fBgem\.push_key\fR (\fBBUNDLE_GEM__PUSH_KEY\fR): Sets the \fB\-\-key\fR parameter for \fBgem push\fR when using the \fBrake release\fR command with a private gemstash server\. +.IP "\(bu" 4 +\fBgemfile\fR (\fBBUNDLE_GEMFILE\fR): The name of the file that bundler should use as the \fBGemfile\fR\. This location of this file also sets the root of the project, which is used to resolve relative paths in the \fBGemfile\fR, among other things\. By default, bundler will search up from the current working directory until it finds a \fBGemfile\fR\. +.IP "\(bu" 4 +\fBglobal_gem_cache\fR (\fBBUNDLE_GLOBAL_GEM_CACHE\fR): Whether Bundler should cache all gems and compiled extensions globally, rather than locally to the configured installation path\. +.IP "\(bu" 4 +\fBignore_funding_requests\fR (\fBBUNDLE_IGNORE_FUNDING_REQUESTS\fR): When set, no funding requests will be printed\. +.IP "\(bu" 4 +\fBignore_messages\fR (\fBBUNDLE_IGNORE_MESSAGES\fR): When set, no post install messages will be printed\. To silence a single gem, use dot notation like \fBignore_messages\.httparty true\fR\. +.IP "\(bu" 4 +\fBinit_gems_rb\fR (\fBBUNDLE_INIT_GEMS_RB\fR): Generate a \fBgems\.rb\fR instead of a \fBGemfile\fR when running \fBbundle init\fR\. +.IP "\(bu" 4 +\fBjobs\fR (\fBBUNDLE_JOBS\fR): The number of gems Bundler can download and install in parallel\. Defaults to the number of available processors\. +.IP "\(bu" 4 +\fBlockfile\fR (\fBBUNDLE_LOCKFILE\fR): The path to the lockfile that bundler should use\. By default, Bundler adds \fB\.lock\fR to the end of the \fBgemfile\fR entry\. Can be set to \fBfalse\fR in the Gemfile to disable lockfile creation entirely (see gemfile(5))\. +.IP "\(bu" 4 +\fBlockfile_checksums\fR (\fBBUNDLE_LOCKFILE_CHECKSUMS\fR): Whether Bundler should include a checksums section in new lockfiles, to protect from compromised gem sources\. Defaults to true\. +.IP "\(bu" 4 +\fBno_install\fR (\fBBUNDLE_NO_INSTALL\fR): Whether \fBbundle package\fR should skip installing gems\. +.IP "\(bu" 4 +\fBno_prune\fR (\fBBUNDLE_NO_PRUNE\fR): Whether Bundler should leave outdated gems unpruned when caching\. +.IP "\(bu" 4 +\fBonly\fR (\fBBUNDLE_ONLY\fR): A space\-separated list of groups to install only gems of the specified groups\. Please check carefully if you want to install also gems without a group, because they get put inside \fBdefault\fR group\. For example \fBonly test:default\fR will install all gems specified in test group and without one\. +.IP "\(bu" 4 +\fBpath\fR (\fBBUNDLE_PATH\fR): The location on disk where all gems in your bundle will be located regardless of \fB$GEM_HOME\fR or \fB$GEM_PATH\fR values\. Bundle gems not found in this location will be installed by \fBbundle install\fR\. When not set, Bundler install by default to a \fB\.bundle\fR directory relative to repository root in Bundler 4, and to the default system path (\fBGem\.dir\fR) before Bundler 4\. That means that before Bundler 4, Bundler shares this location with Rubygems, and \fBgem install \|\.\|\.\|\.\fR will have gems installed in the same location and therefore, gems installed without \fBpath\fR set will show up by calling \fBgem list\fR\. This will not be the case in Bundler 4\. +.IP "\(bu" 4 +\fBpath\.system\fR (\fBBUNDLE_PATH__SYSTEM\fR): Whether Bundler will install gems into the default system path (\fBGem\.dir\fR)\. +.IP "\(bu" 4 +\fBplugins\fR (\fBBUNDLE_PLUGINS\fR): Enable Bundler's experimental plugin system\. +.IP "\(bu" 4 +\fBprefer_patch\fR (\fBBUNDLE_PREFER_PATCH\fR): Prefer updating only to next patch version during updates\. Makes \fBbundle update\fR calls equivalent to \fBbundler update \-\-patch\fR\. +.IP "\(bu" 4 +\fBredirect\fR (\fBBUNDLE_REDIRECT\fR): The number of redirects allowed for network requests\. Defaults to \fB5\fR\. +.IP "\(bu" 4 +\fBretry\fR (\fBBUNDLE_RETRY\fR): The number of times to retry failed network requests\. Defaults to \fB3\fR\. +.IP "\(bu" 4 +\fBshebang\fR (\fBBUNDLE_SHEBANG\fR): The program name that should be invoked for generated binstubs\. Defaults to the ruby install name used to generate the binstub\. +.IP "\(bu" 4 +\fBsilence_deprecations\fR (\fBBUNDLE_SILENCE_DEPRECATIONS\fR): Whether Bundler should silence deprecation warnings for behavior that will be changed in the next major version\. +.IP "\(bu" 4 +\fBsilence_root_warning\fR (\fBBUNDLE_SILENCE_ROOT_WARNING\fR): Silence the warning Bundler prints when installing gems as root\. +.IP "\(bu" 4 +\fBsimulate_version\fR (\fBBUNDLE_SIMULATE_VERSION\fR): The virtual version Bundler should use for activating feature flags\. Can be used to simulate all the new functionality that will be enabled in a future major version\. +.IP "\(bu" 4 +\fBssl_ca_cert\fR (\fBBUNDLE_SSL_CA_CERT\fR): Path to a designated CA certificate file or folder containing multiple certificates for trusted CAs in PEM format\. +.IP "\(bu" 4 +\fBssl_client_cert\fR (\fBBUNDLE_SSL_CLIENT_CERT\fR): Path to a designated file containing a X\.509 client certificate and key in PEM format\. +.IP "\(bu" 4 +\fBssl_verify_mode\fR (\fBBUNDLE_SSL_VERIFY_MODE\fR): The SSL verification mode Bundler uses when making HTTPS requests\. Defaults to verify peer\. +.IP "\(bu" 4 +\fBsystem_bindir\fR (\fBBUNDLE_SYSTEM_BINDIR\fR): The location where RubyGems installs binstubs\. Defaults to \fBGem\.bindir\fR\. +.IP "\(bu" 4 +\fBtimeout\fR (\fBBUNDLE_TIMEOUT\fR): The seconds allowed before timing out for network requests\. Defaults to \fB10\fR\. +.IP "\(bu" 4 +\fBupdate_requires_all_flag\fR (\fBBUNDLE_UPDATE_REQUIRES_ALL_FLAG\fR): Require passing \fB\-\-all\fR to \fBbundle update\fR when everything should be updated, and disallow passing no options to \fBbundle update\fR\. +.IP "\(bu" 4 +\fBuser_agent\fR (\fBBUNDLE_USER_AGENT\fR): The custom user agent fragment Bundler includes in API requests\. +.IP "\(bu" 4 +\fBverbose\fR (\fBBUNDLE_VERBOSE\fR): Whether Bundler should print verbose output\. Defaults to \fBfalse\fR, unless the \fB\-\-verbose\fR CLI flag is used\. +.IP "\(bu" 4 +\fBversion\fR (\fBBUNDLE_VERSION\fR): The version of Bundler to use when running under Bundler environment\. Defaults to \fBlockfile\fR\. You can also specify \fBsystem\fR or \fBx\.y\.z\fR\. \fBlockfile\fR will use the Bundler version specified in the \fBGemfile\.lock\fR, \fBsystem\fR will use the system version of Bundler, and \fBx\.y\.z\fR will use the specified version of Bundler\. +.IP "\(bu" 4 +\fBwith\fR (\fBBUNDLE_WITH\fR): A space\-separated or \fB:\fR\-separated list of groups whose gems bundler should install\. +.IP "\(bu" 4 +\fBwithout\fR (\fBBUNDLE_WITHOUT\fR): A space\-separated or \fB:\fR\-separated list of groups whose gems bundler should not install\. +.IP "" 0 .SH "BUILD OPTIONS" You can use \fBbundle config\fR to give Bundler the flags to pass to the gem installer every time bundler tries to install a particular gem\. .P diff --git a/bundler/lib/bundler/man/bundle-config.1.ronn b/bundler/lib/bundler/man/bundle-config.1.ronn index 81eb67326b9e..d83e9063d509 100644 --- a/bundler/lib/bundler/man/bundle-config.1.ronn +++ b/bundler/lib/bundler/man/bundle-config.1.ronn @@ -137,6 +137,36 @@ learn more about their operation in [bundle install(1)](bundle-install.1.html). explicitly configured. * `console` (`BUNDLE_CONSOLE`): The console that `bundle console` starts. Defaults to `irb`. +* `cooldown` (`BUNDLE_COOLDOWN`): + Number of days a published gem version must age before bundler will + resolve to it. Defaults to unset (no cooldown). Pass `0` to disable + cooldown for an individual run. + + The effective cooldown for any given gem is resolved from three + layers, highest precedence first: + + 1. CLI flag `--cooldown N` on `install`, `update`, `add`, and + `outdated`. + 2. This setting (`bundle config set cooldown N` or + `BUNDLE_COOLDOWN=N`). + 3. The per-source `cooldown:` keyword in the Gemfile, such as + `source "https://rubygems.org", cooldown: 7`. + + The CLI flag and this setting apply uniformly to every source, + including ones declared with their own `cooldown:` value. To keep a + private registry permanently exempt while still cooling down public + gems, declare `source "https://internal", cooldown: 0` in the + Gemfile; remember that `--cooldown N` on the command line will + still override it for that single run. + + Cooldown filtering depends on the gem server providing a per-version + `created_at` timestamp in the v2 compact-index format. Versions + without that metadata - older gem servers, historical entries that + predate the v2 cutover on `rubygems.org`, or private registries that + still emit the v1 format - are treated as outside the cooldown + window and remain resolvable. If you rely on cooldown for + supply-chain protection, confirm that the gem server emits + `created_at` in its `/info/` responses. * `default_cli_command` (`BUNDLE_DEFAULT_CLI_COMMAND`): The command that running `bundle` without arguments should run. Defaults to `cli_help` since Bundler 4, but can also be `install` which was the previous diff --git a/bundler/lib/bundler/man/bundle-install.1 b/bundler/lib/bundler/man/bundle-install.1 index c2f9bfeea1f7..7c2e9eea8dfe 100644 --- a/bundler/lib/bundler/man/bundle-install.1 +++ b/bundler/lib/bundler/man/bundle-install.1 @@ -4,7 +4,7 @@ .SH "NAME" \fBbundle\-install\fR \- Install the dependencies specified in your Gemfile .SH "SYNOPSIS" -\fBbundle install\fR [\-\-force] [\-\-full\-index] [\-\-gemfile=GEMFILE] [\-\-jobs=NUMBER] [\-\-local] [\-\-lockfile=LOCKFILE] [\-\-no\-cache] [\-\-no\-lock] [\-\-prefer\-local] [\-\-quiet] [\-\-retry=NUMBER] [\-\-standalone[=GROUP[ GROUP\|\.\|\.\|\.]]] [\-\-trust\-policy=TRUST\-POLICY] [\-\-target\-rbconfig=TARGET\-RBCONFIG] +\fBbundle install\fR [\-\-cooldown=NUMBER] [\-\-force] [\-\-full\-index] [\-\-gemfile=GEMFILE] [\-\-jobs=NUMBER] [\-\-local] [\-\-lockfile=LOCKFILE] [\-\-no\-cache] [\-\-no\-lock] [\-\-prefer\-local] [\-\-quiet] [\-\-retry=NUMBER] [\-\-standalone[=GROUP[ GROUP\|\.\|\.\|\.]]] [\-\-trust\-policy=TRUST\-POLICY] [\-\-target\-rbconfig=TARGET\-RBCONFIG] .SH "DESCRIPTION" Install the gems specified in your Gemfile(5)\. If this is the first time you run bundle install (and a \fBGemfile\.lock\fR does not exist), Bundler will fetch all remote sources, resolve dependencies and install all needed gems\. .P @@ -13,6 +13,9 @@ If a \fBGemfile\.lock\fR does exist, and you have not updated your Gemfile(5), B If a \fBGemfile\.lock\fR does exist, and you have updated your Gemfile(5), Bundler will use the dependencies in the \fBGemfile\.lock\fR for all gems that you did not update, but will re\-resolve the dependencies of gems that you did update\. You can find more information about this update process below under \fICONSERVATIVE UPDATING\fR\. .SH "OPTIONS" .TP +\fB\-\-cooldown=\fR +Only consider gem versions published at least \fInumber\fR days ago when resolving\. Pass \fB0\fR to disable cooldown for this run, overriding any per\-source or global configuration\. See \fBcooldown\fR in bundle\-config(1) for details on the precedence between the CLI flag, Bundler config, and Gemfile per\-source settings\. +.TP \fB\-\-force\fR, \fB\-\-redownload\fR Force reinstalling every gem, even if already installed\. .TP diff --git a/bundler/lib/bundler/man/bundle-install.1.ronn b/bundler/lib/bundler/man/bundle-install.1.ronn index c7d88bfb73c3..56fd8bdf42a1 100644 --- a/bundler/lib/bundler/man/bundle-install.1.ronn +++ b/bundler/lib/bundler/man/bundle-install.1.ronn @@ -3,7 +3,8 @@ bundle-install(1) -- Install the dependencies specified in your Gemfile ## SYNOPSIS -`bundle install` [--force] +`bundle install` [--cooldown=NUMBER] + [--force] [--full-index] [--gemfile=GEMFILE] [--jobs=NUMBER] @@ -37,6 +38,13 @@ update process below under [CONSERVATIVE UPDATING][]. ## OPTIONS +* `--cooldown=`: + Only consider gem versions published at least days ago when + resolving. Pass `0` to disable cooldown for this run, overriding any + per-source or global configuration. See `cooldown` in bundle-config(1) + for details on the precedence between the CLI flag, Bundler config, + and Gemfile per-source settings. + * `--force`, `--redownload`: Force reinstalling every gem, even if already installed. diff --git a/bundler/lib/bundler/man/bundle-outdated.1 b/bundler/lib/bundler/man/bundle-outdated.1 index 8cdb5a892b97..712b10357028 100644 --- a/bundler/lib/bundler/man/bundle-outdated.1 +++ b/bundler/lib/bundler/man/bundle-outdated.1 @@ -4,7 +4,7 @@ .SH "NAME" \fBbundle\-outdated\fR \- List installed gems with newer versions available .SH "SYNOPSIS" -\fBbundle outdated\fR [GEM] [\-\-local] [\-\-pre] [\-\-source] [\-\-filter\-strict | \-\-strict] [\-\-update\-strict] [\-\-parseable | \-\-porcelain] [\-\-group=GROUP] [\-\-groups] [\-\-patch|\-\-minor|\-\-major] [\-\-filter\-major] [\-\-filter\-minor] [\-\-filter\-patch] [\-\-only\-explicit] +\fBbundle outdated\fR [GEM] [\-\-local] [\-\-pre] [\-\-source] [\-\-filter\-strict | \-\-strict] [\-\-update\-strict] [\-\-parseable | \-\-porcelain] [\-\-group=GROUP] [\-\-groups] [\-\-patch|\-\-minor|\-\-major] [\-\-filter\-major] [\-\-filter\-minor] [\-\-filter\-patch] [\-\-only\-explicit] [\-\-cooldown=NUMBER] .SH "DESCRIPTION" Outdated lists the names and versions of gems that have a newer version available in the given source\. Calling outdated with [GEM [GEM]] will only check for newer versions of the given gems\. Prerelease gems are ignored by default\. If your gems are up to date, Bundler will exit with a status of 0\. Otherwise, it will exit 1\. .SH "OPTIONS" @@ -53,6 +53,9 @@ Only list patch newer versions\. .TP \fB\-\-only\-explicit\fR Only list gems specified in your Gemfile, not their dependencies\. +.TP +\fB\-\-cooldown=\fR +Annotate (rather than hide) versions that are still inside the cooldown window of \fInumber\fR days\. The prose output appends "in cooldown for Nd more days" and the table form adds "(cooldown Nd)" to the Latest column\. See \fBcooldown\fR in bundle\-config(1)\. .SH "PATCH LEVEL OPTIONS" See bundle update(1) \fIbundle\-update\.1\.html\fR for details\. .SH "FILTERING OUTPUT" @@ -61,42 +64,42 @@ The 3 filtering options do not affect the resolution of versions, merely what ve If the regular output shows the following: .IP "" 4 .nf -* Gem Current Latest Requested Groups -* faker 1\.6\.5 1\.6\.6 ~> 1\.4 development, test -* hashie 1\.2\.0 3\.4\.6 = 1\.2\.0 default -* headless 2\.2\.3 2\.3\.1 = 2\.2\.3 test +* Gem Current Latest Requested Groups Release Date +* faker 1\.6\.5 1\.6\.6 ~> 1\.4 development, test 2024\-02\-05 +* hashie 1\.2\.0 3\.4\.6 = 1\.2\.0 default 2023\-11\-10 +* headless 2\.2\.3 2\.3\.1 = 2\.2\.3 test 2022\-08\-19 .fi .IP "" 0 .P \fB\-\-filter\-major\fR would only show: .IP "" 4 .nf -* Gem Current Latest Requested Groups -* hashie 1\.2\.0 3\.4\.6 = 1\.2\.0 default +* Gem Current Latest Requested Groups Release Date +* hashie 1\.2\.0 3\.4\.6 = 1\.2\.0 default 2023\-11\-10 .fi .IP "" 0 .P \fB\-\-filter\-minor\fR would only show: .IP "" 4 .nf -* Gem Current Latest Requested Groups -* headless 2\.2\.3 2\.3\.1 = 2\.2\.3 test +* Gem Current Latest Requested Groups Release Date +* headless 2\.2\.3 2\.3\.1 = 2\.2\.3 test 2022\-08\-19 .fi .IP "" 0 .P \fB\-\-filter\-patch\fR would only show: .IP "" 4 .nf -* Gem Current Latest Requested Groups -* faker 1\.6\.5 1\.6\.6 ~> 1\.4 development, test +* Gem Current Latest Requested Groups Release Date +* faker 1\.6\.5 1\.6\.6 ~> 1\.4 development, test 2024\-02\-05 .fi .IP "" 0 .P Filter options can be combined\. \fB\-\-filter\-minor\fR and \fB\-\-filter\-patch\fR would show: .IP "" 4 .nf -* Gem Current Latest Requested Groups -* faker 1\.6\.5 1\.6\.6 ~> 1\.4 development, test +* Gem Current Latest Requested Groups Release Date +* faker 1\.6\.5 1\.6\.6 ~> 1\.4 development, test 2024\-02\-05 .fi .IP "" 0 .P diff --git a/bundler/lib/bundler/man/bundle-outdated.1.ronn b/bundler/lib/bundler/man/bundle-outdated.1.ronn index 6f67a31977b9..e5badac2e994 100644 --- a/bundler/lib/bundler/man/bundle-outdated.1.ronn +++ b/bundler/lib/bundler/man/bundle-outdated.1.ronn @@ -16,6 +16,7 @@ bundle-outdated(1) -- List installed gems with newer versions available [--filter-minor] [--filter-patch] [--only-explicit] + [--cooldown=NUMBER] ## DESCRIPTION @@ -71,6 +72,12 @@ are up to date, Bundler will exit with a status of 0. Otherwise, it will exit 1. * `--only-explicit`: Only list gems specified in your Gemfile, not their dependencies. +* `--cooldown=`: + Annotate (rather than hide) versions that are still inside the + cooldown window of days. The prose output appends "in + cooldown for Nd more days" and the table form adds "(cooldown Nd)" to + the Latest column. See `cooldown` in bundle-config(1). + ## PATCH LEVEL OPTIONS See [bundle update(1)](bundle-update.1.html) for details. @@ -82,29 +89,29 @@ in the output. If the regular output shows the following: - * Gem Current Latest Requested Groups - * faker 1.6.5 1.6.6 ~> 1.4 development, test - * hashie 1.2.0 3.4.6 = 1.2.0 default - * headless 2.2.3 2.3.1 = 2.2.3 test + * Gem Current Latest Requested Groups Release Date + * faker 1.6.5 1.6.6 ~> 1.4 development, test 2024-02-05 + * hashie 1.2.0 3.4.6 = 1.2.0 default 2023-11-10 + * headless 2.2.3 2.3.1 = 2.2.3 test 2022-08-19 `--filter-major` would only show: - * Gem Current Latest Requested Groups - * hashie 1.2.0 3.4.6 = 1.2.0 default + * Gem Current Latest Requested Groups Release Date + * hashie 1.2.0 3.4.6 = 1.2.0 default 2023-11-10 `--filter-minor` would only show: - * Gem Current Latest Requested Groups - * headless 2.2.3 2.3.1 = 2.2.3 test + * Gem Current Latest Requested Groups Release Date + * headless 2.2.3 2.3.1 = 2.2.3 test 2022-08-19 `--filter-patch` would only show: - * Gem Current Latest Requested Groups - * faker 1.6.5 1.6.6 ~> 1.4 development, test + * Gem Current Latest Requested Groups Release Date + * faker 1.6.5 1.6.6 ~> 1.4 development, test 2024-02-05 Filter options can be combined. `--filter-minor` and `--filter-patch` would show: - * Gem Current Latest Requested Groups - * faker 1.6.5 1.6.6 ~> 1.4 development, test + * Gem Current Latest Requested Groups Release Date + * faker 1.6.5 1.6.6 ~> 1.4 development, test 2024-02-05 Combining all three `filter` options would be the same result as providing none of them. diff --git a/bundler/lib/bundler/man/bundle-update.1 b/bundler/lib/bundler/man/bundle-update.1 index e5f18f2a1e26..f59616f3233f 100644 --- a/bundler/lib/bundler/man/bundle-update.1 +++ b/bundler/lib/bundler/man/bundle-update.1 @@ -4,7 +4,7 @@ .SH "NAME" \fBbundle\-update\fR \- Update your gems to the latest available versions .SH "SYNOPSIS" -\fBbundle update\fR \fI*gems\fR [\-\-all] [\-\-group=NAME] [\-\-source=NAME] [\-\-local] [\-\-ruby] [\-\-bundler[=VERSION]] [\-\-force] [\-\-full\-index] [\-\-gemfile=GEMFILE] [\-\-jobs=NUMBER] [\-\-quiet] [\-\-patch|\-\-minor|\-\-major] [\-\-pre] [\-\-strict] [\-\-conservative] +\fBbundle update\fR \fI*gems\fR [\-\-all] [\-\-group=NAME] [\-\-source=NAME] [\-\-local] [\-\-ruby] [\-\-bundler[=VERSION]] [\-\-cooldown=NUMBER] [\-\-force] [\-\-full\-index] [\-\-gemfile=GEMFILE] [\-\-jobs=NUMBER] [\-\-quiet] [\-\-patch|\-\-minor|\-\-major] [\-\-pre] [\-\-strict] [\-\-conservative] .SH "DESCRIPTION" Update the gems specified (all gems, if \fB\-\-all\fR flag is used), ignoring the previously installed gems specified in the \fBGemfile\.lock\fR\. In general, you should use bundle install(1) \fIbundle\-install\.1\.html\fR to install the same exact gems and versions across machines\. .P @@ -64,6 +64,9 @@ Do not allow any gem to be updated past latest \fB\-\-patch\fR | \fB\-\-minor\fR .TP \fB\-\-conservative\fR Use bundle install conservative update behavior and do not allow indirect dependencies to be updated\. +.TP +\fB\-\-cooldown=\fR +Only consider gem versions published at least \fInumber\fR days ago when resolving\. Pass \fB0\fR to disable cooldown for this run, overriding any per\-source or global configuration\. Combine with \fB\-\-conservative\fR to minimize transitive churn when bypassing cooldown for an urgent update\. See \fBcooldown\fR in bundle\-config(1)\. .SH "UPDATING ALL GEMS" If you run \fBbundle update \-\-all\fR, bundler will ignore any previously installed gems and resolve all dependencies again based on the latest versions of all gems available in the sources\. .P diff --git a/bundler/lib/bundler/man/bundle-update.1.ronn b/bundler/lib/bundler/man/bundle-update.1.ronn index bfe381677c8e..72fbf054d157 100644 --- a/bundler/lib/bundler/man/bundle-update.1.ronn +++ b/bundler/lib/bundler/man/bundle-update.1.ronn @@ -9,6 +9,7 @@ bundle-update(1) -- Update your gems to the latest available versions [--local] [--ruby] [--bundler[=VERSION]] + [--cooldown=NUMBER] [--force] [--full-index] [--gemfile=GEMFILE] @@ -91,6 +92,13 @@ gem. * `--conservative`: Use bundle install conservative update behavior and do not allow indirect dependencies to be updated. +* `--cooldown=`: + Only consider gem versions published at least days ago when + resolving. Pass `0` to disable cooldown for this run, overriding any + per-source or global configuration. Combine with `--conservative` to + minimize transitive churn when bypassing cooldown for an urgent + update. See `cooldown` in bundle-config(1). + ## UPDATING ALL GEMS If you run `bundle update --all`, bundler will ignore diff --git a/bundler/lib/bundler/remote_specification.rb b/bundler/lib/bundler/remote_specification.rb index ab163e2b046a..dcaaf6af2e61 100644 --- a/bundler/lib/bundler/remote_specification.rb +++ b/bundler/lib/bundler/remote_specification.rb @@ -12,7 +12,7 @@ class RemoteSpecification attr_reader :name, :version, :platform attr_writer :dependencies - attr_accessor :source, :remote, :locked_platform + attr_accessor :source, :remote, :locked_platform, :created_at def initialize(name, version, platform, spec_fetcher) @name = name diff --git a/bundler/lib/bundler/resolver.rb b/bundler/lib/bundler/resolver.rb index 3c361d8ea51a..9797addc9bf2 100644 --- a/bundler/lib/bundler/resolver.rb +++ b/bundler/lib/bundler/resolver.rb @@ -184,6 +184,9 @@ def no_versions_incompatibility_for(package, unsatisfied_term) platforms_explanation = specs_matching_other_platforms.any? ? " for any resolution platforms (#{package.platforms.join(", ")})" : "" custom_explanation = "#{constraint} could not be found in #{repository_for(package)}#{platforms_explanation}" + if hint = cooldown_hint(specs_matching_other_platforms) + custom_explanation += " (#{hint})" + end label = "#{name} (#{constraint_string})" extended_explanation = other_specs_matching_message(specs_matching_other_platforms, label) if specs_matching_other_platforms.any? @@ -353,6 +356,10 @@ def raise_not_found!(package) message << "\n#{other_specs_matching_message(specs, matching_part)}" end + if hint = cooldown_hint(specs_matching_requirement) + message << "\n\n#{hint}." + end + if specs_matching_requirement.any? && (hint = platform_mismatch_hint) message << "\n\n#{hint}" end @@ -396,7 +403,7 @@ def filter_matching_specs(specs, requirements) end def filter_specs(specs, package) - filter_remote_specs(filter_prereleases(specs, package), package) + filter_remote_specs(filter_cooldown(filter_prereleases(specs, package)), package) end def filter_prereleases(specs, package) @@ -405,6 +412,40 @@ def filter_prereleases(specs, package) specs.reject {|s| s.version.prerelease? } end + def filter_cooldown(specs) + return specs if specs.empty? + excluded_versions = cooldown_excluded_versions(specs) + return specs if excluded_versions.empty? + specs.reject {|s| excluded_versions.include?([s.name, s.version]) } + end + + def cooldown_excluded_versions(specs) + excluded = {} + specs.each do |spec| + next unless cooldown_excluded?(spec) + excluded[[spec.name, spec.version]] = true + end + excluded + end + + def cooldown_hint(specs) + excluded_versions = cooldown_excluded_versions(specs) + return nil if excluded_versions.empty? + "#{excluded_versions.size} version#{"s" if excluded_versions.size > 1} excluded by the cooldown setting; pass `--cooldown 0` to bypass" + end + + def cooldown_excluded?(spec) + return false unless spec.respond_to?(:created_at) && spec.created_at + return false unless spec.respond_to?(:remote) && spec.remote + days = spec.remote.effective_cooldown + return false if days.nil? || days <= 0 + (cooldown_now - spec.created_at) < (days * 86_400) + end + + def cooldown_now + @cooldown_now ||= Time.now + end + def filter_remote_specs(specs, package) if package.prefer_local? local_specs = specs.select {|s| s.is_a?(StubSpecification) } diff --git a/bundler/lib/bundler/rubygems_ext.rb b/bundler/lib/bundler/rubygems_ext.rb index fedf44b0e69b..4ad2bdf46f04 100644 --- a/bundler/lib/bundler/rubygems_ext.rb +++ b/bundler/lib/bundler/rubygems_ext.rb @@ -465,6 +465,28 @@ def parse(line) Resolver::APISet::GemParser.prepend(UnfreezeCompactIndexParsedResponse) end + # RubyGems before 4.0.13 split compact index dependency/requirement entries + # on every colon, which mangles metadata values that contain colons such as + # the `created_at` timestamps the cooldown feature relies on. Split only on + # the first colon so those values survive on older RubyGems. + # + # The module is defined unconditionally so it stays testable on any RubyGems, + # but only prepended when the host RubyGems still has the buggy behavior. + module SplitCompactIndexEntryOnFirstColon + private + + def parse_dependency(string) + dependency = string.split(":", 2) + dependency[-1] = dependency[-1].split("&") if dependency.size > 1 + dependency[0] = -dependency[0] + dependency + end + end + + unless Gem.rubygems_version >= Gem::Version.new("4.0.13") + Resolver::APISet::GemParser.prepend(SplitCompactIndexEntryOnFirstColon) + end + if Gem.rubygems_version < Gem::Version.new("3.6.0") class Package; end require "rubygems/package/tar_reader" diff --git a/bundler/lib/bundler/rubygems_gem_installer.rb b/bundler/lib/bundler/rubygems_gem_installer.rb index 124058697873..c6b15359613d 100644 --- a/bundler/lib/bundler/rubygems_gem_installer.rb +++ b/bundler/lib/bundler/rubygems_gem_installer.rb @@ -20,7 +20,7 @@ def install strict_rm_rf spec.extension_dir SharedHelpers.filesystem_access(gem_dir, :create) do - FileUtils.mkdir_p gem_dir, mode: 0o755 + FileUtils.mkdir_p gem_dir end SharedHelpers.filesystem_access(gem_dir, :write) do diff --git a/bundler/lib/bundler/settings.rb b/bundler/lib/bundler/settings.rb index 857631a716d7..d49cb9a06d2c 100644 --- a/bundler/lib/bundler/settings.rb +++ b/bundler/lib/bundler/settings.rb @@ -42,6 +42,7 @@ class Settings ].freeze NUMBER_KEYS = %w[ + cooldown jobs redirect retry diff --git a/bundler/lib/bundler/source/git/git_proxy.rb b/bundler/lib/bundler/source/git/git_proxy.rb index 72f7dc771038..8094dcaa9d94 100644 --- a/bundler/lib/bundler/source/git/git_proxy.rb +++ b/bundler/lib/bundler/source/git/git_proxy.rb @@ -432,9 +432,14 @@ def capture(cmd, dir, ignore_err: false) end def capture3_args_for(cmd, dir) - return ["git", *cmd] unless dir + # Disable automatic maintenance so a background commit-graph write in + # the source repo can't race the hardlinking local clone and fail with + # "hardlink different from source". + opts = ["-c", "gc.auto=0", "-c", "maintenance.auto=false"] - ["git", "-C", dir.to_s, *cmd] + return ["git", *opts, *cmd] unless dir + + ["git", "-C", dir.to_s, *opts, *cmd] end def extra_clone_args diff --git a/bundler/lib/bundler/source/rubygems.rb b/bundler/lib/bundler/source/rubygems.rb index 4fb35c18bf4f..7a74d40c7c39 100644 --- a/bundler/lib/bundler/source/rubygems.rb +++ b/bundler/lib/bundler/source/rubygems.rb @@ -16,6 +16,7 @@ class Rubygems < Source def initialize(options = {}) @options = options @remotes = [] + @remote_cooldowns = {} @dependency_names = [] @allow_remote = false @allow_cached = false @@ -25,7 +26,8 @@ def initialize(options = {}) @gem_installers = {} @gem_installers_mutex = Mutex.new - Array(options["remotes"]).reverse_each {|r| add_remote(r) } + cooldown = options["cooldown"] + Array(options["remotes"]).reverse_each {|r| add_remote(r, cooldown: cooldown) } @lockfile_remotes = @remotes if options["from_lockfile"] end @@ -148,6 +150,13 @@ def specs # sources, and large_idx.merge! small_idx is way faster than # small_idx.merge! large_idx. index = @allow_remote ? remote_specs.dup : Index.new + + # Snapshot per-version `created_at` from the remote info before installed + # / cached specs overwrite the EndpointSpecification objects that carry + # it. The cooldown filter consults `created_at` on every candidate, so + # local stubs need the published date back-filled to participate. + remote_created_at = collect_remote_created_at(index) + index.merge!(cached_specs) if @allow_cached index.merge!(installed_specs) if @allow_local @@ -161,6 +170,8 @@ def specs end end + backfill_created_at(index, remote_created_at) unless remote_created_at.empty? + index end end @@ -243,9 +254,14 @@ def cached_built_in_gem(spec, local: false) cached_path end - def add_remote(source) + def add_remote(source, cooldown: nil) uri = normalize_uri(source) @remotes.unshift(uri) unless @remotes.include?(uri) + @remote_cooldowns[uri] = cooldown if cooldown + end + + def cooldown_for(uri) + @remote_cooldowns[uri] end def spec_names @@ -266,7 +282,7 @@ def unmet_deps def remote_fetchers @remote_fetchers ||= remotes.to_h do |uri| - remote = Source::Rubygems::Remote.new(uri) + remote = Source::Rubygems::Remote.new(uri, cooldown: cooldown_for(uri)) [remote, Bundler::Fetcher.new(remote)] end.freeze end @@ -314,6 +330,13 @@ def dependency_api_available? @allow_remote && api_fetchers.any? end + def clear_cache + @specs = nil + @installed_specs = nil + @default_specs = nil + @cached_specs = nil + end + protected def remote_names @@ -456,6 +479,31 @@ def cache_path private + def collect_remote_created_at(index) + return {} unless @allow_remote + + snapshot = {} + index.each do |spec| + next unless spec.respond_to?(:created_at) && spec.created_at + # Remember the remote that supplied the date too: when a source has + # several remotes with different per-URI cooldown settings we must + # restore the same one during backfill so `effective_cooldown` agrees. + snapshot[[spec.name, spec.version]] = [spec.created_at, spec.remote] + end + snapshot + end + + def backfill_created_at(index, snapshot) + index.each do |spec| + next unless spec.respond_to?(:created_at=) + next if spec.created_at + remote_created_at, remote = snapshot[[spec.name, spec.version]] + next unless remote_created_at + spec.created_at = remote_created_at + spec.remote ||= remote if remote && spec.respond_to?(:remote=) + end + end + def lockfile_remotes @lockfile_remotes || credless_remotes end diff --git a/bundler/lib/bundler/source/rubygems/remote.rb b/bundler/lib/bundler/source/rubygems/remote.rb index ed55912a9952..3d847424b7ae 100644 --- a/bundler/lib/bundler/source/rubygems/remote.rb +++ b/bundler/lib/bundler/source/rubygems/remote.rb @@ -4,9 +4,9 @@ module Bundler class Source class Rubygems class Remote - attr_reader :uri, :anonymized_uri, :original_uri + attr_reader :uri, :anonymized_uri, :original_uri, :cooldown - def initialize(uri) + def initialize(uri, cooldown: nil) orig_uri = uri uri = Bundler.settings.mirror_for(uri) @original_uri = orig_uri if orig_uri != uri @@ -14,6 +14,16 @@ def initialize(uri) @uri = apply_auth(uri, fallback_auth).freeze @anonymized_uri = remove_auth(@uri).freeze + @cooldown = cooldown + end + + # Returns the cooldown days that apply to this remote, resolving the + # precedence CLI > config > Gemfile per-source. Returns nil if no + # cooldown applies. + def effective_cooldown + override = Bundler.settings[:cooldown] + return override if override + @cooldown end MAX_CACHE_SLUG_HOST_SIZE = 255 - 1 - 32 # 255 minus dot minus MD5 length diff --git a/bundler/lib/bundler/source_list.rb b/bundler/lib/bundler/source_list.rb index 38fa0972e64e..ab7002d6e5b3 100644 --- a/bundler/lib/bundler/source_list.rb +++ b/bundler/lib/bundler/source_list.rb @@ -59,8 +59,8 @@ def add_plugin_source(source, options = {}) add_source_to_list Plugin.source(source).new(options), @plugin_sources end - def add_global_rubygems_remote(uri) - global_rubygems_source.add_remote(uri) + def add_global_rubygems_remote(uri, cooldown: nil) + global_rubygems_source.add_remote(uri, cooldown: cooldown) global_rubygems_source end @@ -136,6 +136,10 @@ def remote! all_sources.each(&:remote!) end + def clear_cache + rubygems_sources.each(&:clear_cache) + end + private def map_sources(replacement_sources) diff --git a/bundler/lib/bundler/version.rb b/bundler/lib/bundler/version.rb index 405bd339ae48..0c30aeef16bc 100644 --- a/bundler/lib/bundler/version.rb +++ b/bundler/lib/bundler/version.rb @@ -1,7 +1,7 @@ # frozen_string_literal: false module Bundler - VERSION = "4.0.12".freeze + VERSION = "4.0.13".freeze def self.bundler_major_version @bundler_major_version ||= gem_version.segments.first diff --git a/bundler/spec/bundler/compact_index_client/parser_spec.rb b/bundler/spec/bundler/compact_index_client/parser_spec.rb index 6015f66f33a7..6aa867f058f9 100644 --- a/bundler/spec/bundler/compact_index_client/parser_spec.rb +++ b/bundler/spec/bundler/compact_index_client/parser_spec.rb @@ -47,7 +47,7 @@ def set_info_data(name, value) INFO let(:c_info) { <<~INFO } 3.0.0 a:= 1.0.0,b:~> 2.0|checksum:ccc1,ruby:>= 2.7.0,rubygems:>= 3.0.0 - 3.3.3 a:>= 1.1.0,b:~> 2.0|checksum:ccc3,ruby:>= 3.0.0,rubygems:>= 3.2.3 + 3.3.3 a:>= 1.1.0,b:~> 2.0|checksum:ccc3,ruby:>= 3.0.0,rubygems:>= 3.2.3,created_at:2026-05-12T10:00:00Z INFO describe "#available?" do @@ -195,7 +195,7 @@ def set_info_data(name, value) "3.3.3", nil, [["a", [">= 1.1.0"]], ["b", ["~> 2.0"]]], - [["checksum", ["ccc3"]], ["ruby", [">= 3.0.0"]], ["rubygems", [">= 3.2.3"]]], + [["checksum", ["ccc3"]], ["ruby", [">= 3.0.0"]], ["rubygems", [">= 3.2.3"]], ["created_at", ["2026-05-12T10:00:00Z"]]], ], ] end diff --git a/bundler/spec/bundler/dsl_spec.rb b/bundler/spec/bundler/dsl_spec.rb index a19f251be5d9..b36998fb8228 100644 --- a/bundler/spec/bundler/dsl_spec.rb +++ b/bundler/spec/bundler/dsl_spec.rb @@ -366,4 +366,46 @@ end end end + + describe "#source with cooldown" do + before do + allow(@rubygems).to receive(:add_remote) + end + + it "accepts a non-negative integer" do + expect do + subject.source("https://rubygems.org", cooldown: 7) + end.not_to raise_error + end + + it "accepts 0 as an explicit disable" do + expect do + subject.source("https://rubygems.org", cooldown: 0) + end.not_to raise_error + end + + it "rejects a string" do + expect do + subject.source("https://rubygems.org", cooldown: "7") + end.to raise_error(Bundler::InvalidOption, /non-negative integer/) + end + + it "rejects a float" do + expect do + subject.source("https://rubygems.org", cooldown: 7.5) + end.to raise_error(Bundler::InvalidOption, /non-negative integer/) + end + + it "rejects a negative integer" do + expect do + subject.source("https://rubygems.org", cooldown: -7) + end.to raise_error(Bundler::InvalidOption, /non-negative integer/) + end + + it "rejects an array" do + expect do + subject.source("https://rubygems.org", cooldown: [7]) + end.to raise_error(Bundler::InvalidOption, /non-negative integer/) + end + end end diff --git a/bundler/spec/bundler/endpoint_specification_spec.rb b/bundler/spec/bundler/endpoint_specification_spec.rb index 6518f125ba5e..4fbd59d48f23 100644 --- a/bundler/spec/bundler/endpoint_specification_spec.rb +++ b/bundler/spec/bundler/endpoint_specification_spec.rb @@ -46,6 +46,46 @@ ) end end + + context "when the metadata has created_at" do + let(:metadata) { { "created_at" => ["2026-05-12T10:00:00Z"] } } + + it "parses created_at as a Time" do + expect(subject.created_at).to eq(Time.utc(2026, 5, 12, 10, 0, 0)) + end + end + + context "when the metadata has a string created_at (older rubygems shape)" do + let(:metadata) { { "created_at" => "2026-05-12T10:00:00Z" } } + + it "still parses created_at" do + expect(subject.created_at).to eq(Time.utc(2026, 5, 12, 10, 0, 0)) + end + end + + context "when created_at is truncated (older rubygems splits on colons)" do + let(:metadata) { { "created_at" => "2026-05-12T10" } } + + it "leaves created_at as nil instead of raising" do + expect(subject.created_at).to be_nil + end + end + + context "when the metadata has no created_at" do + let(:metadata) { { "checksum" => ["abc"] } } + let(:spec_fetcher) { double(:spec_fetcher, uri: "https://rubygems.org") } + + it "leaves created_at as nil" do + allow(Bundler::Checksum).to receive(:from_api).and_return(nil) + expect(subject.created_at).to be_nil + end + end + + context "when the metadata is nil" do + it "leaves created_at as nil" do + expect(subject.created_at).to be_nil + end + end end describe "#required_ruby_version" do diff --git a/bundler/spec/bundler/resolver/cooldown_spec.rb b/bundler/spec/bundler/resolver/cooldown_spec.rb new file mode 100644 index 000000000000..37ec158cba4f --- /dev/null +++ b/bundler/spec/bundler/resolver/cooldown_spec.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +RSpec.describe Bundler::Resolver do + let(:resolver) { described_class.allocate } + + def remote(cooldown:) + instance_double(Bundler::Source::Rubygems::Remote, effective_cooldown: cooldown) + end + + def spec(created_at:, remote:, name: "myrack", version: "1.0.0") + Struct.new(:name, :version, :created_at, :remote).new(name, Gem::Version.new(version), created_at, remote) + end + + describe "#filter_cooldown" do + let(:now) { Time.now } + + context "with a 7-day cooldown" do + let(:r) { remote(cooldown: 7) } + + it "rejects versions published within the window" do + recent = spec(version: "1.1.0", created_at: now - (2 * 86_400), remote: r) + old = spec(version: "1.0.0", created_at: now - (30 * 86_400), remote: r) + + expect(resolver.send(:filter_cooldown, [recent, old])).to eq([old]) + end + + it "keeps versions published exactly at the threshold" do + boundary = spec(created_at: now - (7 * 86_400), remote: r) + + expect(resolver.send(:filter_cooldown, [boundary])).to eq([boundary]) + end + + it "leaves rolling-delay history intact" do + # 7-day cooldown with frequent releases must still expose an older candidate. + in_cooldown = spec(version: "1.2.0", created_at: now - 86_400, remote: r) + also_in_cooldown = spec(version: "1.1.0", created_at: now - (3 * 86_400), remote: r) + eligible = spec(version: "1.0.0", created_at: now - (10 * 86_400), remote: r) + + result = resolver.send(:filter_cooldown, [in_cooldown, also_in_cooldown, eligible]) + + expect(result).to eq([eligible]) + end + + it "drops every spec sharing an excluded [name, version] tuple" do + # The cooldown check is by version, not per-spec: a StubSpecification for an + # in-cooldown release would otherwise slip through on local install paths. + endpoint = spec(version: "2.0.0", created_at: now - 86_400, remote: r) + local_stub = Struct.new(:name, :version).new("myrack", Gem::Version.new("2.0.0")) + eligible = spec(version: "1.0.0", created_at: now - (30 * 86_400), remote: r) + + result = resolver.send(:filter_cooldown, [endpoint, local_stub, eligible]) + + expect(result).to eq([eligible]) + end + + it "keeps stub-only versions that no endpoint marks as in cooldown" do + # If no remote spec carries created_at for a version, cooldown cannot judge it; + # the stub stays in. + local_only = Struct.new(:name, :version).new("myrack", Gem::Version.new("2.0.0")) + eligible = spec(version: "1.0.0", created_at: now - (30 * 86_400), remote: r) + + result = resolver.send(:filter_cooldown, [local_only, eligible]) + + expect(result).to eq([local_only, eligible]) + end + end + + context "when created_at is missing (blank metadata)" do + it "keeps the spec regardless of cooldown" do + s = spec(created_at: nil, remote: remote(cooldown: 7)) + + expect(resolver.send(:filter_cooldown, [s])).to eq([s]) + end + end + + context "when the remote has no cooldown" do + it "keeps every spec" do + s = spec(created_at: now - 3600, remote: remote(cooldown: nil)) + + expect(resolver.send(:filter_cooldown, [s])).to eq([s]) + end + end + + context "when cooldown is 0" do + it "keeps every spec (escape hatch)" do + s = spec(created_at: now - 3600, remote: remote(cooldown: 0)) + + expect(resolver.send(:filter_cooldown, [s])).to eq([s]) + end + end + + context "when the spec does not respond to created_at" do + it "keeps the spec" do + bare = Struct.new(:version).new("1.0.0") + + expect(resolver.send(:filter_cooldown, [bare])).to eq([bare]) + end + end + + context "when the spec has no remote" do + it "keeps the spec" do + s = spec(created_at: now - 86_400, remote: nil) + + expect(resolver.send(:filter_cooldown, [s])).to eq([s]) + end + end + + it "returns the same array when input is empty" do + expect(resolver.send(:filter_cooldown, [])).to eq([]) + end + end + + describe "#cooldown_hint" do + let(:now) { Time.now } + let(:r) { remote(cooldown: 7) } + + it "returns nil when no spec is excluded" do + expect(resolver.send(:cooldown_hint, [])).to be_nil + end + + it "returns nil when every spec is outside the cooldown window" do + eligible = [spec(created_at: now - (30 * 86_400), remote: r)] + + expect(resolver.send(:cooldown_hint, eligible)).to be_nil + end + + it "mentions the count and the bypass flag for one excluded version" do + excluded = [spec(created_at: now - 86_400, remote: r)] + + hint = resolver.send(:cooldown_hint, excluded) + + expect(hint).to match(/1 version excluded by the cooldown setting/) + expect(hint).to match(/--cooldown 0/) + end + + it "uses plural wording when multiple versions are excluded" do + excluded = %w[1.0.0 1.1.0 1.2.0].map {|v| spec(version: v, created_at: now - 86_400, remote: r) } + + expect(resolver.send(:cooldown_hint, excluded)).to match(/3 versions excluded/) + end + + it "counts each unique version once even when multiple spec instances share it" do + duplicates = Array.new(3) { spec(created_at: now - 86_400, remote: r) } + + expect(resolver.send(:cooldown_hint, duplicates)).to match(/1 version excluded/) + end + end +end diff --git a/bundler/spec/bundler/rubygems_ext_spec.rb b/bundler/spec/bundler/rubygems_ext_spec.rb new file mode 100644 index 000000000000..0fc528f78c7b --- /dev/null +++ b/bundler/spec/bundler/rubygems_ext_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "bundler/rubygems_ext" + +RSpec.describe Gem::SplitCompactIndexEntryOnFirstColon do + # Reproduces the RubyGems < 4.0.13 `Gem::Resolver::APISet::GemParser` that + # split each compact index entry on every colon, corrupting metadata values + # that themselves contain colons. + let(:legacy_parser_class) do + Class.new do + def parse_dependency(string) + dependency = string.split(":") + dependency[-1] = dependency[-1].split("&") if dependency.size > 1 + dependency[0] = -dependency[0] + dependency + end + end + end + + before { legacy_parser_class.prepend(described_class) } + + it "preserves colon-bearing metadata values such as created_at timestamps" do + parser = legacy_parser_class.new + + expect(parser.send(:parse_dependency, "created_at:2026-05-12T10:00:00Z")).to eq(["created_at", ["2026-05-12T10:00:00Z"]]) + end + + it "still parses ordinary name:requirement entries" do + parser = legacy_parser_class.new + + expect(parser.send(:parse_dependency, "myrack:>= 1.0")).to eq(["myrack", [">= 1.0"]]) + end + + it "keeps parse_dependency private" do + parser = legacy_parser_class.new + + expect { parser.parse_dependency("created_at:x") }.to raise_error(NoMethodError, /private method/) + end +end diff --git a/bundler/spec/bundler/settings_spec.rb b/bundler/spec/bundler/settings_spec.rb index 39a8b36b3d65..0109ccd8fb8c 100644 --- a/bundler/spec/bundler/settings_spec.rb +++ b/bundler/spec/bundler/settings_spec.rb @@ -119,6 +119,11 @@ settings.set_local :ssl_verify_mode, "1" expect(settings[:ssl_verify_mode]).to be 1 end + + it "coerces cooldown to integer" do + settings.set_local :cooldown, "7" + expect(settings[:cooldown]).to be 7 + end end context "when it's not possible to create the settings directory" do diff --git a/bundler/spec/bundler/source/rubygems/remote_spec.rb b/bundler/spec/bundler/source/rubygems/remote_spec.rb index f2214ca8fe12..27430d4a3bb7 100644 --- a/bundler/spec/bundler/source/rubygems/remote_spec.rb +++ b/bundler/spec/bundler/source/rubygems/remote_spec.rb @@ -169,4 +169,39 @@ def remote(uri) end end end + + describe "#cooldown" do + it "is nil by default" do + expect(remote(uri_no_auth).cooldown).to be_nil + end + + it "returns the value passed to the constructor" do + r = Bundler::Source::Rubygems::Remote.new(uri_no_auth, cooldown: 7) + expect(r.cooldown).to eq(7) + end + end + + describe "#effective_cooldown" do + it "returns the per-remote value when no override is set" do + r = Bundler::Source::Rubygems::Remote.new(uri_no_auth, cooldown: 7) + expect(r.effective_cooldown).to eq(7) + end + + it "returns nil when neither override nor per-remote value is set" do + expect(remote(uri_no_auth).effective_cooldown).to be_nil + end + + it "settings override per-remote value" do + r = Bundler::Source::Rubygems::Remote.new(uri_no_auth, cooldown: 7) + Bundler.settings.temporary(cooldown: 14) do + expect(r.effective_cooldown).to eq(14) + end + end + + it "settings override even when per-remote value is absent" do + Bundler.settings.temporary(cooldown: 14) do + expect(remote(uri_no_auth).effective_cooldown).to eq(14) + end + end + end end diff --git a/bundler/spec/bundler/source/rubygems_spec.rb b/bundler/spec/bundler/source/rubygems_spec.rb index dde4e4ed4769..feb787498e74 100644 --- a/bundler/spec/bundler/source/rubygems_spec.rb +++ b/bundler/spec/bundler/source/rubygems_spec.rb @@ -45,6 +45,45 @@ end end + describe "#clear_cache" do + it "invalidates memoized indexes so subsequent reads rebuild them" do + source = described_class.new + + first_specs = source.specs + first_installed = source.send(:installed_specs) + first_default = source.send(:default_specs) + first_cached = source.send(:cached_specs) + + expect(source.specs).to equal(first_specs) + expect(source.send(:installed_specs)).to equal(first_installed) + expect(source.send(:default_specs)).to equal(first_default) + expect(source.send(:cached_specs)).to equal(first_cached) + + source.clear_cache + + expect(source.specs).not_to equal(first_specs) + expect(source.send(:installed_specs)).not_to equal(first_installed) + expect(source.send(:default_specs)).not_to equal(first_default) + expect(source.send(:cached_specs)).not_to equal(first_cached) + end + + it "reflects newly-discovered installed gems after clear_cache" do + source = described_class.new + foo = Gem::Specification.new("foo", "1.0.0") + bar = Gem::Specification.new("bar", "1.0.0") + + allow(Bundler.rubygems).to receive(:installed_specs).and_return([foo]) + expect(source.send(:installed_specs).search("bar")).to be_empty + + allow(Bundler.rubygems).to receive(:installed_specs).and_return([foo, bar]) + expect(source.send(:installed_specs).search("bar")).to be_empty + + source.clear_cache + + expect(source.send(:installed_specs).search("bar")).not_to be_empty + end + end + describe "log debug information" do it "log the time spent downloading and installing a gem" do build_repo2 do diff --git a/bundler/spec/bundler/source_list_spec.rb b/bundler/spec/bundler/source_list_spec.rb index 6e0be6c92fcc..61bd99b063b4 100644 --- a/bundler/spec/bundler/source_list_spec.rb +++ b/bundler/spec/bundler/source_list_spec.rb @@ -129,6 +129,12 @@ Gem::URI("https://rubygems.org/"), ] end + + it "records the per-remote cooldown when supplied" do + source_list.add_global_rubygems_remote("https://othersource.org", cooldown: 7) + expect(returned_source.cooldown_for(Gem::URI("https://othersource.org/"))).to eq(7) + expect(returned_source.cooldown_for(Gem::URI("https://rubygems.org/"))).to be_nil + end end describe "#add_plugin_source" do @@ -442,6 +448,16 @@ end end + describe "#clear_cache" do + let(:rubygems_source) { source_list.add_rubygems_source("remotes" => ["https://rubygems.org"]) } + + it "calls #clear_cache on all rubygems sources" do + expect(rubygems_source).to receive(:clear_cache) + expect(source_list.global_rubygems_source).to receive(:clear_cache) + source_list.clear_cache + end + end + describe "implicit_global_source?" do context "when a global rubygem source provided" do it "returns a falsy value" do diff --git a/bundler/spec/commands/install_spec.rb b/bundler/spec/commands/install_spec.rb index 3c68c2cb30d1..e20b79acde9e 100644 --- a/bundler/spec/commands/install_spec.rb +++ b/bundler/spec/commands/install_spec.rb @@ -1306,6 +1306,43 @@ def run end end + describe "when using umask 002 and setgid bit", :permissions do + let(:gems_path) { bundled_app("vendor/#{Bundler.ruby_scope}/gems") } + let(:foo_path) { gems_path.join("foo-1.0.0") } + + before do + build_repo4 do + build_gem "foo", "1.0.0" do |s| + s.write "CHANGELOG.md", "foo" + end + end + + gemfile <<-G + source "https://gem.repo4" + gem 'foo' + G + + FileUtils.mkdir_p(gems_path) + FileUtils.chmod("g+s", gems_path) + end + + it "should create the gem directory with proper permissions" do + with_umask(0o002) do + bundle "config set --local path vendor" + bundle :install + expect(out).to include("Bundle complete!") + expect(err).to be_empty + # Linux's SysV-derived mkdir(2) propagates the set-group-ID bit + # from the parent directory to newly created subdirectories. BSD + # (including macOS) inherits the parent's group via mkdir(2) but + # does not copy the set-group-ID bit itself, so the expected + # mode differs by platform. + expected = RUBY_PLATFORM.include?("darwin") ? 0o0775 : 0o2775 + expect(File.stat(foo_path).mode & 0o7777).to eq(expected) + end + end + end + describe "parallel make" do before do unless Gem::Installer.private_method_defined?(:build_jobs) diff --git a/bundler/spec/commands/newgem_spec.rb b/bundler/spec/commands/newgem_spec.rb index 281c7a0ddd30..235b9afb9a3d 100644 --- a/bundler/spec/commands/newgem_spec.rb +++ b/bundler/spec/commands/newgem_spec.rb @@ -1774,7 +1774,7 @@ def create_temporary_dir(dir) it "configures the crate such that `cargo test` works", :ruby_repo, :mri_only do env = setup_rust_env gem_path = bundled_app(gem_name) - result = sys_exec("cargo test", env: env, dir: gem_path) + result = sys_exec("cargo test", env: env, dir: gem_path, timeout: 300) expect(result).to include("1 passed") end diff --git a/bundler/spec/commands/outdated_spec.rb b/bundler/spec/commands/outdated_spec.rb index d8633d12e82b..e5460e55a5ab 100644 --- a/bundler/spec/commands/outdated_spec.rb +++ b/bundler/spec/commands/outdated_spec.rb @@ -30,7 +30,7 @@ bundle "outdated", raise_on_error: false expected_output = <<~TABLE.gsub("x", "\\\h").tr(".", "\.").strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date activesupport 2.3.5 3.0 = 2.3.5 default foo 1.0 xxxxxxx 1.0 xxxxxxx >= 0 default weakling 0.0.3 0.2 ~> 0.0.1 default @@ -53,7 +53,7 @@ bundle "outdated", raise_on_error: false expected_output = <<~TABLE - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date AAA 1.0.0 2.0.0 = 1.0.0 default TABLE @@ -92,7 +92,7 @@ bundle "outdated", raise_on_error: false expected_output = <<~TABLE.strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date activesupport 2.3.5 3.0 = 2.3.5 development, test terranova 8 9 = 8 default TABLE @@ -142,9 +142,9 @@ bundle "outdated --verbose", raise_on_error: false expected_output = <<~TABLE.strip - Gem Current Latest Requested Groups Path + Gem Current Latest Requested Groups Release Date Path activesupport 2.3.5 3.0 = 2.3.5 default - terranova 8 9 = 8 default #{default_bundle_path("specifications/terranova-9.gemspec")} + terranova 8 9 = 8 default #{default_bundle_path("specifications/terranova-9.gemspec")} TABLE expect(out).to end_with(expected_output) @@ -197,7 +197,7 @@ def test_group_option(group) test_group_option("default") expected_output = <<~TABLE.strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date terranova 8 9 = 8 default TABLE @@ -208,7 +208,7 @@ def test_group_option(group) test_group_option("development") expected_output = <<~TABLE.strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date activesupport 2.3.5 3.0 = 2.3.5 development, test duradura 7.0 8.0 = 7.0 development, test TABLE @@ -220,7 +220,7 @@ def test_group_option(group) test_group_option("test") expected_output = <<~TABLE.strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date activesupport 2.3.5 3.0 = 2.3.5 development, test duradura 7.0 8.0 = 7.0 development, test TABLE @@ -257,7 +257,7 @@ def test_group_option(group) bundle "outdated --groups", raise_on_error: false expected_output = <<~TABLE.strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date bar 2.0.0 3.0.0 TABLE @@ -299,7 +299,7 @@ def test_group_option(group) bundle "outdated --groups", raise_on_error: false expected_output = <<~TABLE.strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date activesupport 2.3.5 3.0 = 2.3.5 development, test duradura 7.0 8.0 = 7.0 development, test terranova 8 9 = 8 default @@ -343,7 +343,7 @@ def test_group_option(group) bundle "outdated --local", raise_on_error: false expected_output = <<~TABLE.strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date activesupport 2.3.4 2.3.5 = 2.3.4 default TABLE @@ -454,7 +454,7 @@ def test_group_option(group) bundle "outdated foo", raise_on_error: false expected_output = <<~TABLE.gsub("x", "\\\h").tr(".", "\.").strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date foo 1.0 xxxxxxx 1.0 xxxxxxx >= 0 default TABLE @@ -491,7 +491,7 @@ def test_group_option(group) bundle "outdated zeitwerk", raise_on_error: false expected_output = <<~TABLE.tr(".", "\.").strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date zeitwerk 1.0.0 2.0.0 >= 0 default TABLE @@ -539,7 +539,7 @@ def test_group_option(group) bundle "outdated --pre", raise_on_error: false expected_output = <<~TABLE.strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date activesupport 2.3.5 3.0.0.beta = 2.3.5 default TABLE @@ -562,7 +562,7 @@ def test_group_option(group) bundle "outdated", raise_on_error: false expected_output = <<~TABLE.strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date activesupport 3.0.0.beta.1 3.0.0.beta.2 = 3.0.0.beta.1 default TABLE @@ -598,7 +598,7 @@ def test_group_option(group) bundle :outdated, "filter-strict": true, raise_on_error: false expected_output = <<~TABLE.strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date weakling 0.0.3 0.0.5 ~> 0.0.1 default TABLE @@ -614,7 +614,7 @@ def test_group_option(group) bundle :outdated, strict: true, raise_on_error: false expected_output = <<~TABLE.strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date weakling 0.0.3 0.0.5 ~> 0.0.1 default TABLE @@ -659,7 +659,7 @@ def test_group_option(group) bundle :outdated, :"filter-strict" => true, "filter-patch" => true, :raise_on_error => false expected_output = <<~TABLE.strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date weakling 0.0.3 0.0.5 >= 0.0.1 default TABLE @@ -681,7 +681,7 @@ def test_group_option(group) bundle :outdated, :"filter-strict" => true, "filter-minor" => true, :raise_on_error => false expected_output = <<~TABLE.strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date weakling 0.0.3 0.1.5 >= 0.0.1 default TABLE @@ -703,7 +703,7 @@ def test_group_option(group) bundle :outdated, :"filter-strict" => true, "filter-major" => true, :raise_on_error => false expected_output = <<~TABLE.strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date weakling 0.0.3 1.1.5 >= 0.0.1 default TABLE @@ -847,7 +847,7 @@ def test_group_option(group) bundle "outdated", raise_on_error: false expected_output = <<~TABLE.strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date laduradura 5.15.2 5.15.3 = 5.15.2 default TABLE @@ -1149,7 +1149,7 @@ def test_group_option(group) bundle "outdated --patch --filter-patch", raise_on_error: false expected_output = <<~TABLE.strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date major 1.0.0 1.0.1 >= 0 default minor 1.0.0 1.0.1 >= 0 default patch 1.0.0 1.0.1 >= 0 default @@ -1162,7 +1162,7 @@ def test_group_option(group) bundle "outdated --minor --filter-minor", raise_on_error: false expected_output = <<~TABLE.strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date major 1.0.0 1.1.0 >= 0 default minor 1.0.0 1.1.0 >= 0 default TABLE @@ -1216,7 +1216,7 @@ def test_group_option(group) bundle "outdated --patch --filter-patch", raise_on_error: false, env: { "DEBUG_RESOLVER" => "1" } expected_output = <<~TABLE.strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date bar 2.0.3 2.0.5 foo 1.4.3 1.4.4 >= 0 default TABLE @@ -1228,7 +1228,7 @@ def test_group_option(group) bundle "outdated --patch --filter-patch", raise_on_error: false, env: { "DEBUG" => "1" } expected_output = <<~TABLE.strip - Gem Current Latest Requested Groups Path + Gem Current Latest Requested Groups Release Date Path bar 2.0.3 2.0.5 foo 1.4.3 1.4.4 >= 0 default TABLE @@ -1260,7 +1260,7 @@ def test_group_option(group) bundle "outdated --only-explicit", raise_on_error: false expected_output = <<~TABLE.strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date weakling 0.2 0.3 >= 0 default TABLE @@ -1310,7 +1310,7 @@ def test_group_option(group) bundle "outdated", raise_on_error: false expected_output = <<~TABLE.strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date nokogiri 1.11.1 1.11.2 >= 0 default TABLE @@ -1359,7 +1359,7 @@ def test_group_option(group) bundle "outdated", raise_on_error: false expected_output = <<~TABLE.strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date mini_portile2 2.5.2 2.5.3 >= 0 default TABLE diff --git a/bundler/spec/commands/platform_spec.rb b/bundler/spec/commands/platform_spec.rb index 71ccbb0909bd..1e233e0917b0 100644 --- a/bundler/spec/commands/platform_spec.rb +++ b/bundler/spec/commands/platform_spec.rb @@ -1143,7 +1143,7 @@ def should_be_patchlevel_fixnum bundle "outdated", raise_on_error: false expected_output = <<~TABLE.gsub("x", "\\\h").tr(".", "\.").strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date activesupport 2.3.5 3.0 = 2.3.5 default foo 1.0 xxxxxxx 1.0 xxxxxxx >= 0 default TABLE @@ -1169,7 +1169,7 @@ def should_be_patchlevel_fixnum bundle "outdated", raise_on_error: false expected_output = <<~TABLE.gsub("x", "\\\h").tr(".", "\.").strip - Gem Current Latest Requested Groups + Gem Current Latest Requested Groups Release Date activesupport 2.3.5 3.0 = 2.3.5 default foo 1.0 xxxxxxx 1.0 xxxxxxx >= 0 default TABLE diff --git a/bundler/spec/install/cooldown_spec.rb b/bundler/spec/install/cooldown_spec.rb new file mode 100644 index 000000000000..b3f57d93ccf5 --- /dev/null +++ b/bundler/spec/install/cooldown_spec.rb @@ -0,0 +1,272 @@ +# frozen_string_literal: true + +RSpec.describe "bundle install with the cooldown setting" do + before do + build_repo2 + end + + context "Gemfile DSL" do + it "accepts `source ..., cooldown: N` without error" do + install_gemfile <<-G, artifice: "compact_index" + source "https://gem.repo2", cooldown: 5 + gem "myrack" + G + + expect(the_bundle).to include_gems("myrack 1.0.0") + end + + it "accepts `cooldown: 0` to disable cooldown for a source" do + install_gemfile <<-G, artifice: "compact_index" + source "https://gem.repo2", cooldown: 0 + gem "myrack" + G + + expect(the_bundle).to include_gems("myrack 1.0.0") + end + end + + context "CLI flag" do + before do + gemfile <<-G + source "https://gem.repo2" + gem "myrack" + G + end + + it "accepts --cooldown N on install" do + bundle "install --cooldown 7", artifice: "compact_index" + + expect(the_bundle).to include_gems("myrack 1.0.0") + end + + it "accepts --cooldown 0 as an escape hatch" do + bundle "install --cooldown 0", artifice: "compact_index" + + expect(the_bundle).to include_gems("myrack 1.0.0") + end + + it "rejects a negative --cooldown value" do + bundle "install --cooldown=-7", artifice: "compact_index", raise_on_error: false + + expect(err).to match(/non-negative integer/) + end + end + + context "configuration" do + it "reads BUNDLE_COOLDOWN as an integer" do + gemfile <<-G + source "https://gem.repo2" + gem "myrack" + G + + bundle "install", env: { "BUNDLE_COOLDOWN" => "7" }, artifice: "compact_index" + + expect(the_bundle).to include_gems("myrack 1.0.0") + end + + it "reads `bundle config set cooldown N`" do + gemfile <<-G + source "https://gem.repo2" + gem "myrack" + G + + bundle "config set cooldown 7" + bundle "install", artifice: "compact_index" + + expect(the_bundle).to include_gems("myrack 1.0.0") + end + end + + context "end-to-end with v2 compact index" do + before do + now = Time.now.utc + build_repo3 do + build_gem "ripe_gem", "1.0.0" do |s| + s.date = now - (30 * 86_400) + end + build_gem "ripe_gem", "2.0.0" do |s| + s.date = now - (1 * 86_400) + end + end + end + + it "excludes versions within the cooldown window" do + gemfile <<-G + source "https://gem.repo3" + gem "ripe_gem" + G + + bundle "install --cooldown 7", artifice: "compact_index_cooldown" + + expect(the_bundle).to include_gems("ripe_gem 1.0.0") + end + + it "selects the latest version when --cooldown 0 is passed" do + gemfile <<-G + source "https://gem.repo3" + gem "ripe_gem" + G + + bundle "install --cooldown 0", artifice: "compact_index_cooldown" + + expect(the_bundle).to include_gems("ripe_gem 2.0.0") + end + + it "applies cooldown declared per-source in the Gemfile" do + gemfile <<-G + source "https://gem.repo3", cooldown: 7 + gem "ripe_gem" + G + + bundle "install", artifice: "compact_index_cooldown" + + expect(the_bundle).to include_gems("ripe_gem 1.0.0") + end + + it "is overridden by CLI --cooldown when Gemfile sets a different per-source value" do + gemfile <<-G + source "https://gem.repo3", cooldown: 0 + gem "ripe_gem" + G + + bundle "install --cooldown 7", artifice: "compact_index_cooldown" + + expect(the_bundle).to include_gems("ripe_gem 1.0.0") + end + + it "bypasses cooldown when bundle install uses an existing lockfile" do + gemfile <<-G + source "https://gem.repo3" + gem "ripe_gem" + G + + lockfile <<-L + GEM + remote: https://gem.repo3/ + specs: + ripe_gem (2.0.0) + + PLATFORMS + #{lockfile_platforms} + + DEPENDENCIES + ripe_gem + + BUNDLED WITH + #{Bundler::VERSION} + L + + bundle "install --cooldown 7", artifice: "compact_index_cooldown" + + expect(the_bundle).to include_gems("ripe_gem 2.0.0") + end + + it "annotates in-cooldown versions in bundle outdated table output" do + gemfile <<-G + source "https://gem.repo3" + gem "ripe_gem", "1.0.0" + G + + lockfile <<-L + GEM + remote: https://gem.repo3/ + specs: + ripe_gem (1.0.0) + + PLATFORMS + #{lockfile_platforms} + + DEPENDENCIES + ripe_gem (= 1.0.0) + + BUNDLED WITH + #{Bundler::VERSION} + L + + bundle "outdated --cooldown 7", artifice: "compact_index_cooldown", raise_on_error: false + + expect(out).to match(/ripe_gem.*\(cooldown \d+d\)/) + end + + it "annotates in-cooldown versions in bundle outdated --parseable output" do + gemfile <<-G + source "https://gem.repo3" + gem "ripe_gem", "1.0.0" + G + + lockfile <<-L + GEM + remote: https://gem.repo3/ + specs: + ripe_gem (1.0.0) + + PLATFORMS + #{lockfile_platforms} + + DEPENDENCIES + ripe_gem (= 1.0.0) + + BUNDLED WITH + #{Bundler::VERSION} + L + + bundle "outdated --cooldown 7 --parseable", artifice: "compact_index_cooldown", raise_on_error: false + + expect(out).to match(/ripe_gem.*in cooldown for \d+ more day/) + end + + it "excludes a locally-installed version that is still within the cooldown window" do + system_gems "ripe_gem-2.0.0", gem_repo: gem_repo3 + + gemfile <<-G + source "https://gem.repo3" + gem "ripe_gem" + G + + bundle "install --cooldown 7", artifice: "compact_index_cooldown" + + expect(the_bundle).to include_gems("ripe_gem 1.0.0") + end + + it "selects a locally-installed in-cooldown version when --cooldown 0 bypasses the filter" do + system_gems "ripe_gem-2.0.0", gem_repo: gem_repo3 + + gemfile <<-G + source "https://gem.repo3" + gem "ripe_gem" + G + + bundle "install --cooldown 0", artifice: "compact_index_cooldown" + + expect(the_bundle).to include_gems("ripe_gem 2.0.0") + end + + it "surfaces a cooldown hint when bundle update filters every candidate" do + gemfile <<-G + source "https://gem.repo3" + gem "ripe_gem" + G + + lockfile <<-L + GEM + remote: https://gem.repo3/ + specs: + ripe_gem (1.0.0) + + PLATFORMS + #{lockfile_platforms} + + DEPENDENCIES + ripe_gem + + BUNDLED WITH + #{Bundler::VERSION} + L + + bundle "update ripe_gem --cooldown 99999", artifice: "compact_index_cooldown", raise_on_error: false + + expect(err).to match(/excluded by the cooldown setting/) + expect(err).to match(/--cooldown 0/) + end + end +end diff --git a/bundler/spec/install/process_lock_spec.rb b/bundler/spec/install/process_lock_spec.rb index 344caa3a9312..b096291d1a92 100644 --- a/bundler/spec/install/process_lock_spec.rb +++ b/bundler/spec/install/process_lock_spec.rb @@ -53,5 +53,61 @@ expect(processed).to eq true end end + + it "refreshes gem specification cache after waiting for lock" do + build_repo2 do + build_gem "myrack", "1.0.0" + end + + gemfile <<-G + source "https://gem.repo2" + gem "myrack" + G + + # First, install the gem so it's available + bundle "install" + expect(out).to include("Installing myrack") + + # Queue for thread-safe communication + lock_acquired = Queue.new + can_release_lock = Queue.new + install_output = Queue.new + + # Thread holds lock (simulating another bundle process that just finished installing) + thread = Thread.new do + Bundler::ProcessLock.lock(default_bundle_path) do + # Signal that we have the lock + lock_acquired << true + # Wait until main thread signals we can release + can_release_lock.pop + end + end + + # Wait for thread to acquire lock + lock_acquired.pop + + # Start another install in a thread - it will wait for the lock + install_thread = Thread.new do + bundle "install", verbose: true + install_output << out + end + + # Give subprocess time to start and begin waiting for lock + sleep 0.5 + + # Signal thread to release the lock + can_release_lock << true + + # Wait for both threads to complete + thread.join + install_thread.join + + second_install_out = install_output.pop + + expect(the_bundle).to include_gems "myrack 1.0.0" + # The second install should have refreshed its cache after acquiring + # the lock and seen that myrack was already installed + expect(second_install_out).to include("Using myrack") + end end end diff --git a/bundler/spec/realworld/fixtures/tapioca/Gemfile.lock b/bundler/spec/realworld/fixtures/tapioca/Gemfile.lock index 347fd5aaa470..c12c0089a9f9 100644 --- a/bundler/spec/realworld/fixtures/tapioca/Gemfile.lock +++ b/bundler/spec/realworld/fixtures/tapioca/Gemfile.lock @@ -46,4 +46,4 @@ DEPENDENCIES tapioca BUNDLED WITH - 4.0.12 + 4.0.13 diff --git a/bundler/spec/realworld/fixtures/warbler/Gemfile.lock b/bundler/spec/realworld/fixtures/warbler/Gemfile.lock index 795dfdac6641..1103f9e7d6f5 100644 --- a/bundler/spec/realworld/fixtures/warbler/Gemfile.lock +++ b/bundler/spec/realworld/fixtures/warbler/Gemfile.lock @@ -36,4 +36,4 @@ DEPENDENCIES warbler! BUNDLED WITH - 4.0.12 + 4.0.13 diff --git a/bundler/spec/support/artifice/compact_index_cooldown.rb b/bundler/spec/support/artifice/compact_index_cooldown.rb new file mode 100644 index 000000000000..85e3173c989c --- /dev/null +++ b/bundler/spec/support/artifice/compact_index_cooldown.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +require_relative "helpers/compact_index_cooldown" +require_relative "helpers/artifice" + +Artifice.activate_with(CompactIndexCooldownAPI) diff --git a/bundler/spec/support/artifice/helpers/compact_index.rb b/bundler/spec/support/artifice/helpers/compact_index.rb index e61fe921ec3b..e684aa862879 100644 --- a/bundler/spec/support/artifice/helpers/compact_index.rb +++ b/bundler/spec/support/artifice/helpers/compact_index.rb @@ -2,7 +2,7 @@ require_relative "endpoint" -$LOAD_PATH.unshift Dir[Spec::Path.scoped_base_system_gem_path.join("gems/compact_index*/lib")].first.to_s +$LOAD_PATH.unshift Spec::Path.tmp_root.join("compact_index/lib").to_s require "compact_index" require "digest" @@ -90,13 +90,17 @@ def gems(gem_repo = default_gem_repo) rescue StandardError checksum = nil end - CompactIndex::GemVersion.new(spec.version.version, spec.platform.to_s, checksum, nil, - deps, spec.required_ruby_version.to_s, spec.required_rubygems_version.to_s) + build_gem_version(spec, deps, checksum) end CompactIndex::Gem.new(name, gem_versions) end end end + + def build_gem_version(spec, deps, checksum) + CompactIndex::GemVersion.new(spec.version.version, spec.platform.to_s, checksum, nil, + deps, spec.required_ruby_version.to_s, spec.required_rubygems_version.to_s) + end end get "/names" do diff --git a/bundler/spec/support/artifice/helpers/compact_index_cooldown.rb b/bundler/spec/support/artifice/helpers/compact_index_cooldown.rb new file mode 100644 index 000000000000..9920fd2c9520 --- /dev/null +++ b/bundler/spec/support/artifice/helpers/compact_index_cooldown.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require_relative "compact_index" + +class CompactIndexCooldownAPI < CompactIndexAPI + helpers do + def build_gem_version(spec, deps, checksum) + created_at = spec.date&.utc&.iso8601 + CompactIndex::GemVersionV2.new(spec.version.version, spec.platform.to_s, checksum, nil, + deps, spec.required_ruby_version.to_s, spec.required_rubygems_version.to_s, created_at) + end + end +end diff --git a/bundler/spec/support/command_execution.rb b/bundler/spec/support/command_execution.rb index 979a46549a3a..e2915b996d9d 100644 --- a/bundler/spec/support/command_execution.rb +++ b/bundler/spec/support/command_execution.rb @@ -72,7 +72,7 @@ def failure? attr_reader :failure_reason def normalize(string) - string.force_encoding(Encoding::UTF_8).strip.gsub("\r\n", "\n") + string.dup.force_encoding(Encoding::UTF_8).scrub.strip.gsub("\r\n", "\n") end end end diff --git a/bundler/spec/support/rubygems_ext.rb b/bundler/spec/support/rubygems_ext.rb index 2d681529aac2..403d5104a4fd 100644 --- a/bundler/spec/support/rubygems_ext.rb +++ b/bundler/spec/support/rubygems_ext.rb @@ -62,6 +62,48 @@ def install_test_deps require_relative "helpers" Helpers.install_dev_bundler + + install_vendored_compact_index + end + + # Vendor `rubygems/rubygems.org#lib/compact_index/` under `tmp/compact_index/` + # so the artifice can serve compact-index responses without a runtime gem + # dependency. Pinned to a reviewed commit; override with COMPACT_INDEX_REF + # to refresh against another ref (the existing vendor copy is discarded). + def install_vendored_compact_index + target_root = Path.tmp_root.join("compact_index") + require "fileutils" + FileUtils.mkdir_p(Path.tmp_root) + + files = %w[ + lib/compact_index.rb + lib/compact_index/dependency.rb + lib/compact_index/gem.rb + lib/compact_index/gem_version.rb + lib/compact_index/versions_file.rb + ] + + # Serialize installs so parallel test setups don't race on the same + # vendor tree, and only skip the download when every file is present so + # an interrupted run can't leave a partial copy behind. + File.open(Path.tmp_root.join("compact_index.lock"), File::CREAT | File::RDWR) do |lock| + lock.flock(File::LOCK_EX) + + FileUtils.rm_rf(target_root) if ENV["COMPACT_INDEX_REF"] + + next if files.all? {|path| File.exist?(target_root.join(path)) } + + require "open-uri" + ref = ENV["COMPACT_INDEX_REF"] || "7c68a7b39761c61a66f9299f85b889ec39afc02c" + files.each do |path| + url = "https://raw.githubusercontent.com/rubygems/rubygems.org/#{ref}/#{path}" + target = target_root.join(path) + FileUtils.mkdir_p(File.dirname(target)) + tmp = "#{target}.tmp" + File.write(tmp, URI.parse(url).open(&:read)) + File.rename(tmp, target) + end + end end def check_source_control_changes(success_message:, error_message:) diff --git a/bundler/spec/support/windows_tag_group.rb b/bundler/spec/support/windows_tag_group.rb index bd6acb9d55ca..a20a7fe814d0 100644 --- a/bundler/spec/support/windows_tag_group.rb +++ b/bundler/spec/support/windows_tag_group.rb @@ -142,6 +142,9 @@ module WindowsTagGroup "spec/bundler/ci_detector_spec.rb", ], windows_d: [ + "spec/bundler/rubygems_ext_spec.rb", + "spec/bundler/resolver/cooldown_spec.rb", + "spec/install/cooldown_spec.rb", "spec/commands/outdated_spec.rb", "spec/commands/update_spec.rb", "spec/lock/lockfile_spec.rb", diff --git a/lib/rubygems.rb b/lib/rubygems.rb index 1adb74dded26..ade5d15997ba 100644 --- a/lib/rubygems.rb +++ b/lib/rubygems.rb @@ -9,7 +9,7 @@ require "rbconfig" module Gem - VERSION = "4.0.12" + VERSION = "4.0.13" end require_relative "rubygems/defaults" diff --git a/lib/rubygems/ext/builder.rb b/lib/rubygems/ext/builder.rb index 62d36bcf48d3..e00cf159da3e 100644 --- a/lib/rubygems/ext/builder.rb +++ b/lib/rubygems/ext/builder.rb @@ -102,7 +102,8 @@ def self.run(command, results, command_name = nil, dir = Dir.pwd, env = {}) # Set $SOURCE_DATE_EPOCH for the subprocess. build_env = { "SOURCE_DATE_EPOCH" => Gem.source_date_epoch_string }.merge(env) output, status = begin - Open3.popen2e(build_env, *command, chdir: dir) do |_stdin, stdouterr, wait_thread| + Open3.popen2e(build_env, *command, chdir: dir) do |stdin, stdouterr, wait_thread| + stdin.close output = String.new while line = stdouterr.gets output << line diff --git a/lib/rubygems/package.rb b/lib/rubygems/package.rb index f9c5676d8034..7bf4d0f08653 100644 --- a/lib/rubygems/package.rb +++ b/lib/rubygems/package.rb @@ -449,6 +449,11 @@ def extract_tar_gz(io, destination_dir, pattern = "*") # :nodoc: directories << mkdir end + real_mkdir = File.realpath(mkdir) + unless real_mkdir == destination_dir || normalize_path(real_mkdir).start_with?(normalize_path(destination_dir + "/")) + raise Gem::Package::PathError.new(real_mkdir, destination_dir) + end + if entry.file? File.open(destination, "wb") do |out| copy_stream(tar.io, out, entry.size) @@ -466,7 +471,7 @@ def extract_tar_gz(io, destination_dir, pattern = "*") # :nodoc: symlinks.each do |name, target, destination, real_destination| if File.exist?(real_destination) - File.symlink(target, destination) + create_symlink(target, destination) else alert_warning "#{@spec.full_name} ships with a dangling symlink named #{name} pointing to missing #{target} file. Ignoring" end @@ -747,6 +752,21 @@ def limit_read(io, name, limit) raise Gem::Package::FormatError, "#{name} is too big (over #{limit} bytes)" if bytes.size > limit bytes end + + if Gem.win_platform? + # Create a symlink and fallback to copy the file or directory on Windows, + # where symlink creation needs special privileges in form of the Developer Mode. + def create_symlink(old_name, new_name) + File.symlink(old_name, new_name) + rescue Errno::EACCES + from = File.expand_path(old_name, File.dirname(new_name)) + FileUtils.cp_r(from, new_name) + end + else + def create_symlink(old_name, new_name) + File.symlink(old_name, new_name) + end + end end require_relative "package/digest_io" diff --git a/lib/rubygems/resolver/api_set/gem_parser.rb b/lib/rubygems/resolver/api_set/gem_parser.rb index 7dd9a89ebcdf..4d827f498088 100644 --- a/lib/rubygems/resolver/api_set/gem_parser.rb +++ b/lib/rubygems/resolver/api_set/gem_parser.rb @@ -13,7 +13,7 @@ def parse(line) private def parse_dependency(string) - dependency = string.split(":") + dependency = string.split(":", 2) dependency[-1] = dependency[-1].split("&") if dependency.size > 1 dependency[0] = -dependency[0] dependency diff --git a/test/rubygems/helper.rb b/test/rubygems/helper.rb index dc40f4ecb1f8..12a20340375c 100644 --- a/test/rubygems/helper.rb +++ b/test/rubygems/helper.rb @@ -1238,6 +1238,24 @@ def nmake_found? system("nmake /? 1>NUL 2>&1") end + @@symlink_supported = nil + + # This is needed for Windows environment without symlink support enabled (the default + # for non admin) to be able to skip test for features using symlinks. + def symlink_supported? + if @@symlink_supported.nil? + begin + File.symlink(File.join(@tempdir, "a"), File.join(@tempdir, "b")) + rescue NotImplementedError, SystemCallError + @@symlink_supported = false + else + File.unlink(File.join(@tempdir, "b")) + @@symlink_supported = true + end + end + @@symlink_supported + end + # In case we're building docs in a background process, this method waits for # that process to exit (or if it's already been reaped, or never happened, # swallows the Errno::ECHILD error). diff --git a/test/rubygems/installer_test_case.rb b/test/rubygems/installer_test_case.rb index ded205c5f562..9e0cbf9c692b 100644 --- a/test/rubygems/installer_test_case.rb +++ b/test/rubygems/installer_test_case.rb @@ -237,21 +237,4 @@ def test_ensure_writable_dir_creates_missing_parent_directories assert_directory_exists non_existent_parent, "Parent directory should exist now" assert_directory_exists target_dir, "Target directory should exist now" end - - @@symlink_supported = nil - - # This is needed for Windows environment without symlink support enabled (the default - # for non admin) to be able to skip test for features using symlinks. - def symlink_supported? - if @@symlink_supported.nil? - begin - File.symlink("", "") - rescue Errno::ENOENT, Errno::EEXIST - @@symlink_supported = true - rescue NotImplementedError, SystemCallError - @@symlink_supported = false - end - end - @@symlink_supported - end end diff --git a/test/rubygems/test_gem_commands_owner_command.rb b/test/rubygems/test_gem_commands_owner_command.rb index dc3391fd4215..988b7bf84ffa 100644 --- a/test/rubygems/test_gem_commands_owner_command.rb +++ b/test/rubygems/test_gem_commands_owner_command.rb @@ -399,7 +399,6 @@ def test_with_webauthn_enabled_success end def test_with_webauthn_enabled_failure - pend "Flaky on TruffleRuby" if RUBY_ENGINE == "truffleruby" response_success = "Owner added successfully." server = Gem::MockTCPServer.new error = Gem::WebauthnVerificationError.new("Something went wrong") @@ -417,7 +416,8 @@ def test_with_webauthn_enabled_failure end end - assert_match @stub_fetcher.last_request["Authorization"], Gem.configuration.rubygems_api_key + webauthn_verification_request = @stub_fetcher.requests.find {|req| req.path == "/api/v1/webauthn_verification" } + assert_match webauthn_verification_request["Authorization"], Gem.configuration.rubygems_api_key assert_match "You have enabled multi-factor authentication. Please visit the following URL " \ "to authenticate via security device. If you can't verify using WebAuthn but have OTP enabled, " \ "you can re-run the gem signin command with the `--otp [your_code]` option.", @stub_ui.output diff --git a/test/rubygems/test_gem_ext_builder.rb b/test/rubygems/test_gem_ext_builder.rb index 5fcbc3e2acfd..37204f3c472a 100644 --- a/test/rubygems/test_gem_ext_builder.rb +++ b/test/rubygems/test_gem_ext_builder.rb @@ -106,6 +106,22 @@ def test_custom_make_with_options assert_match(/install: OK/, results) end + def test_class_run_closes_stdin + results = [] + check_stdin_script = <<~'RUBY' + if IO.select([STDIN], nil, nil, 1) + puts "STDIN: #{STDIN.read.inspect}" + else + puts "NOT_READY" + end + RUBY + + Gem::Ext::Builder.run([Gem.ruby, "-e", check_stdin_script], results) + + command_output = results.last + assert_equal "STDIN: \"\"\n", command_output + end + def test_build_extensions pend "terminates on mswin" if vc_windows? && ruby_repo? diff --git a/test/rubygems/test_gem_installer.rb b/test/rubygems/test_gem_installer.rb index 293fe1e823dd..e47288ab8e54 100644 --- a/test/rubygems/test_gem_installer.rb +++ b/test/rubygems/test_gem_installer.rb @@ -759,8 +759,12 @@ def test_generate_bin_with_dangling_symlink errors = @ui.error.split("\n") assert_equal "WARNING: ascii_binder-0.1.10.1 ships with a dangling symlink named bin/ascii_binder pointing to missing bin/asciibinder file. Ignoring", errors.shift - assert_empty errors - + if symlink_supported? + assert_empty errors + else + assert_match(/Unable to use symlinks, installing wrapper/i, + errors.to_s) + end assert_empty @ui.output end diff --git a/test/rubygems/test_gem_package.rb b/test/rubygems/test_gem_package.rb index 2ad63acd03bb..fbfc1d2aa0de 100644 --- a/test/rubygems/test_gem_package.rb +++ b/test/rubygems/test_gem_package.rb @@ -175,6 +175,9 @@ def test_add_files end def test_add_files_symlink + unless symlink_supported? + omit("symlink - developer mode must be enabled on Windows") + end spec = Gem::Specification.new spec.files = %w[lib/code.rb lib/code_sym.rb lib/code_sym2.rb] @@ -185,16 +188,8 @@ def test_add_files_symlink end # NOTE: 'code.rb' is correct, because it's relative to lib/code_sym.rb - begin - File.symlink("code.rb", "lib/code_sym.rb") - File.symlink("../lib/code.rb", "lib/code_sym2.rb") - rescue Errno::EACCES => e - if Gem.win_platform? - pend "symlink - must be admin with no UAC on Windows" - else - raise e - end - end + File.symlink("code.rb", "lib/code_sym.rb") + File.symlink("../lib/code.rb", "lib/code_sym2.rb") package = Gem::Package.new "bogus.gem" package.spec = spec @@ -583,25 +578,71 @@ def test_extract_tar_gz_symlink_relative_path tar.add_symlink "lib/foo.rb", "../relative.rb", 0o644 end - begin - package.extract_tar_gz tgz_io, @destination - rescue Errno::EACCES => e - if Gem.win_platform? - pend "symlink - must be admin with no UAC on Windows" - else - raise e - end - end + package.extract_tar_gz tgz_io, @destination extracted = File.join @destination, "lib/foo.rb" assert_path_exist extracted - assert_equal "../relative.rb", - File.readlink(extracted) + if symlink_supported? + assert_equal "../relative.rb", + File.readlink(extracted) + end assert_equal "hi", + File.read(extracted), + "should read file content either by following symlink or on Windows by reading copy" + end + + def test_extract_tar_gz_symlink_directory + package = Gem::Package.new @gem + package.verify + + tgz_io = util_tar_gz do |tar| + tar.add_symlink "link", "lib/orig", 0o644 + tar.mkdir "lib", 0o755 + tar.mkdir "lib/orig", 0o755 + tar.add_file "lib/orig/file.rb", 0o644 do |io| + io.write "ok" + end + end + + package.extract_tar_gz tgz_io, @destination + extracted = File.join @destination, "link/file.rb" + assert_path_exist extracted + if symlink_supported? + assert_equal "lib/orig", + File.readlink(File.dirname(extracted)) + end + assert_equal "ok", File.read(extracted) end + def test_extract_tar_gz_rejects_preexisting_symlink_escape + omit "Symlinks not supported or not enabled" unless symlink_supported? + + package = Gem::Package.new @gem + + tgz_io = util_tar_gz do |tar| + tar.add_file "lib/owned.txt", 0o644 do |io| + io.write "poc-content" + end + end + + escape_dir = File.join(@tempdir, "escape") + FileUtils.mkdir_p escape_dir + + FileUtils.rm_rf File.join(@destination, "lib") + File.symlink escape_dir, File.join(@destination, "lib") + + escaped = File.join(escape_dir, "owned.txt") + + assert_raise Gem::Package::PathError do + package.extract_tar_gz tgz_io, @destination + end + + refute File.exist?(escaped), "must not write outside extraction root via symlink" + end + def test_extract_symlink_into_symlink_dir + omit "Symlinks not supported or not enabled" unless symlink_supported? package = Gem::Package.new @gem tgz_io = util_tar_gz do |tar| tar.mkdir "lib", 0o755 @@ -665,14 +706,10 @@ def test_extract_symlink_parent destination_subdir = File.join @destination, "subdir" FileUtils.mkdir_p destination_subdir - expected_exceptions = Gem.win_platform? ? [Gem::Package::SymlinkError, Errno::EACCES] : [Gem::Package::SymlinkError] - - e = assert_raise(*expected_exceptions) do + e = assert_raise(Gem::Package::SymlinkError) do package.extract_tar_gz tgz_io, destination_subdir end - pend "symlink - must be admin with no UAC on Windows" if Errno::EACCES === e - assert_equal("installing symlink 'lib/link' pointing to parent path #{@destination} of " \ "#{destination_subdir} is not allowed", e.message) @@ -700,14 +737,10 @@ def test_extract_symlink_parent_doesnt_delete_user_dir tar.add_symlink "link/dir", ".", 16_877 end - expected_exceptions = Gem.win_platform? ? [Gem::Package::SymlinkError, Errno::EACCES] : [Gem::Package::SymlinkError] - - e = assert_raise(*expected_exceptions) do + e = assert_raise(Gem::Package::SymlinkError) do package.extract_tar_gz tgz_io, destination_subdir end - pend "symlink - must be admin with no UAC on Windows" if Errno::EACCES === e - assert_equal("installing symlink 'link' pointing to parent path #{destination_user_dir} of " \ "#{destination_subdir} is not allowed", e.message) diff --git a/tool/bundler/dev_gems.rb.lock b/tool/bundler/dev_gems.rb.lock index 4b7ac8d612b9..223142f43f39 100644 --- a/tool/bundler/dev_gems.rb.lock +++ b/tool/bundler/dev_gems.rb.lock @@ -129,4 +129,4 @@ CHECKSUMS turbo_tests (2.2.5) sha256=3fa31497d12976d11ccc298add29107b92bda94a90d8a0a5783f06f05102509f BUNDLED WITH - 4.0.12 + 4.0.13 diff --git a/tool/bundler/lint_gems.rb.lock b/tool/bundler/lint_gems.rb.lock index b959288a2d65..0537a9650c3d 100644 --- a/tool/bundler/lint_gems.rb.lock +++ b/tool/bundler/lint_gems.rb.lock @@ -119,4 +119,4 @@ CHECKSUMS wmi-lite (1.0.7) sha256=116ef5bb470dbe60f58c2db9047af3064c16245d6562c646bc0d90877e27ddda BUNDLED WITH - 4.0.12 + 4.0.13 diff --git a/tool/bundler/release_gems.rb.lock b/tool/bundler/release_gems.rb.lock index a7edf0627313..194218fabcd2 100644 --- a/tool/bundler/release_gems.rb.lock +++ b/tool/bundler/release_gems.rb.lock @@ -87,4 +87,4 @@ CHECKSUMS uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 BUNDLED WITH - 4.0.12 + 4.0.13 diff --git a/tool/bundler/rubocop_gems.rb.lock b/tool/bundler/rubocop_gems.rb.lock index 7c1610f2d94b..cd53bcb91a1f 100644 --- a/tool/bundler/rubocop_gems.rb.lock +++ b/tool/bundler/rubocop_gems.rb.lock @@ -156,4 +156,4 @@ CHECKSUMS unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f BUNDLED WITH - 4.0.12 + 4.0.13 diff --git a/tool/bundler/standard_gems.rb.lock b/tool/bundler/standard_gems.rb.lock index 24e33d943a0d..49df9b7e1296 100644 --- a/tool/bundler/standard_gems.rb.lock +++ b/tool/bundler/standard_gems.rb.lock @@ -176,4 +176,4 @@ CHECKSUMS unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f BUNDLED WITH - 4.0.12 + 4.0.13 diff --git a/tool/bundler/test_gems.rb b/tool/bundler/test_gems.rb index ddc19e2939d4..86ca5cd6d623 100644 --- a/tool/bundler/test_gems.rb +++ b/tool/bundler/test_gems.rb @@ -4,7 +4,6 @@ gem "rack", "~> 3.1" gem "rack-test", "~> 2.1" -gem "compact_index", "~> 0.15.0" gem "sinatra", "~> 4.1" gem "rake", "~> 13.1" gem "builder", "~> 3.2" diff --git a/tool/bundler/test_gems.rb.lock b/tool/bundler/test_gems.rb.lock index ccb9db178541..f15b88cc0fac 100644 --- a/tool/bundler/test_gems.rb.lock +++ b/tool/bundler/test_gems.rb.lock @@ -59,7 +59,6 @@ PLATFORMS DEPENDENCIES builder (~> 3.2) - compact_index (~> 0.15.0) concurrent-ruby etc fiddle @@ -103,4 +102,4 @@ CHECKSUMS tilt (2.6.1) sha256=35a99bba2adf7c1e362f5b48f9b581cce4edfba98117e34696dde6d308d84770 BUNDLED WITH - 4.0.12 + 4.0.13 diff --git a/tool/bundler/vendor_gems.rb.lock b/tool/bundler/vendor_gems.rb.lock index 4619364802ee..1218e5668ca9 100644 --- a/tool/bundler/vendor_gems.rb.lock +++ b/tool/bundler/vendor_gems.rb.lock @@ -72,4 +72,4 @@ CHECKSUMS uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 BUNDLED WITH - 4.0.12 + 4.0.13