diff --git a/.circleci/config.yml b/.circleci/config.yml index f607a245..296c2bda 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -109,6 +109,7 @@ commands: - run: name: Install Dependencies command: | + export RUBYOPT="-r${PWD}/.circleci/global_pinner" bundle check || bundle install run_sonarqube: steps: @@ -197,6 +198,72 @@ commands: command: | bundle exec rake release[origin] jobs: + update-currency-versions: + executor: + name: base + ruby_version: "3.3" + steps: + - checkout + - run: + name: Update RubyGems and Bundler + command: | + gem update --system + gem install bundler + bundle config set path './vendor/bundle' + - run: + name: Capture installed gem versions and test with latest versions + command: | + #!/usr/bin/env bash + set -e + mkdir -p bundle-list + mkdir -p test-results + + run_bundle_list_and_test() { + local label="$1" + local gemfile="$2" + echo "=== Processing $label from $gemfile ===" + + # Install dependencies + BUNDLE_GEMFILE="$gemfile" bundle check 2>/dev/null \ + || BUNDLE_GEMFILE="$gemfile" bundle install --quiet + + # Capture installed versions + BUNDLE_GEMFILE="$gemfile" bundle list > "bundle-list/installed_${label}.txt" + echo " Captured $(grep -c '^\s\+\*' bundle-list/installed_${label}.txt) gems" + + # Run tests to verify the versions work + echo " Running tests for $label..." + if BUNDLE_GEMFILE="$gemfile" bundle exec rake test 2>&1 | tee "test-results/${label}_test.log"; then + echo "PASS" > "test-results/${label}_status.txt" + echo " ✓ Tests passed for $label" + else + echo "FAIL" > "test-results/${label}_status.txt" + echo " ✗ Tests failed for $label" + fi + } + + run_bundle_list_and_test "rails" "./gemfiles/rails_80.gemfile" + run_bundle_list_and_test "sinatra" "./gemfiles/sinatra_40.gemfile" + run_bundle_list_and_test "rack" "./gemfiles/rack_30.gemfile" + run_bundle_list_and_test "excon" "./gemfiles/excon_100.gemfile" + run_bundle_list_and_test "rest-client" "./gemfiles/rest_client_20.gemfile" + run_bundle_list_and_test "redis" "./gemfiles/redis_50.gemfile" + run_bundle_list_and_test "mongo" "./gemfiles/mongo_219.gemfile" + run_bundle_list_and_test "sequel" "./gemfiles/sequel_58.gemfile" + run_bundle_list_and_test "bunny" "./gemfiles/bunny_300.gemfile" + run_bundle_list_and_test "sidekiq" "./gemfiles/sidekiq_70.gemfile" + run_bundle_list_and_test "resque" "./gemfiles/resque_20.gemfile" + run_bundle_list_and_test "grpc" "./gemfiles/grpc_10.gemfile" + run_bundle_list_and_test "aws" "./gemfiles/aws_60.gemfile" + run_bundle_list_and_test "cuba" "./gemfiles/cuba_40.gemfile" + run_bundle_list_and_test "dalli" "./gemfiles/dalli_32.gemfile" + run_bundle_list_and_test "graphql" "./gemfiles/graphql_20.gemfile" + run_bundle_list_and_test "roda" "./gemfiles/roda_30.gemfile" + run_bundle_list_and_test "net-http" "./gemfiles/net_http_01.gemfile" + - store_artifacts: + path: bundle-list + - store_artifacts: + path: test-results test_core: parameters: stack: @@ -268,6 +335,7 @@ workflows: tags: only: /^v.*/ core: + max_auto_reruns: 2 jobs: - lint - test_core: @@ -281,7 +349,19 @@ workflows: - "3.3" - "3.4" - "4.0" + - update-currency-versions: + filters: + branches: + only: + - master + requires: + - lint + - test_core-ruby-base-3.2 + - test_core-ruby-base-3.3 + - test_core-ruby-base-3.4 + - test_core-ruby-base-4.0 libraries_ruby_32_33: + max_auto_reruns: 2 jobs: - test_apprisal: name: "test_apprisal-<>-ruby-<>-<>" @@ -294,6 +374,7 @@ workflows: - "3.2" - "3.3" libraries_ruby_34_40: + max_auto_reruns: 2 jobs: - test_apprisal: name: "test_apprisal-<>-ruby-<>-<>" @@ -317,6 +398,7 @@ workflows: ruby_version: "4.0" gemfile: "./gemfiles/grpc_10.gemfile" rails_ruby_32_40: + max_auto_reruns: 2 jobs: - test_apprisal: name: "test_apprisal-rails-<>-ruby-<>-<>" @@ -336,6 +418,7 @@ workflows: - "3.4" - "4.0" rails8_ruby_32_40: + max_auto_reruns: 2 jobs: - test_apprisal: name: "test_apprisal-rails-8-<>-ruby-<>-<>" @@ -353,23 +436,24 @@ workflows: - "3.4" - "4.0" sequel: - jobs: - - test_apprisal: - name: "test_apprisal-sequel-<>-ruby-<>-<>" - matrix: - parameters: - stack: - - base - - mysql2 - gemfile: - - "./gemfiles/sequel_56.gemfile" - - "./gemfiles/sequel_57.gemfile" - - "./gemfiles/sequel_58.gemfile" - ruby_version: - - "3.2" - - "3.3" - - "3.4" - - "4.0" + max_auto_reruns: 2 + jobs: + - test_apprisal: + name: "test_apprisal-sequel-<>-ruby-<>-<>" + matrix: + parameters: + stack: + - base + - mysql2 + gemfile: + - "./gemfiles/sequel_56.gemfile" + - "./gemfiles/sequel_57.gemfile" + - "./gemfiles/sequel_58.gemfile" + ruby_version: + - "3.2" + - "3.3" + - "3.4" + - "4.0" report_coverage: jobs: - report_coverage diff --git a/.circleci/global_pinner.rb b/.circleci/global_pinner.rb new file mode 100644 index 00000000..00d519f3 --- /dev/null +++ b/.circleci/global_pinner.rb @@ -0,0 +1,168 @@ +# global_pinner.rb +require 'json' +require 'net/http' +require 'time' +require 'pathname' +require 'rubygems/requirement' + +DAYS_BACK = 5 +SECONDS_PER_DAY = 24 * 60 * 60 +TARGET_DATE = Time.now.utc - (DAYS_BACK * SECONDS_PER_DAY) +RUBYGEMS_HOST = 'https://rubygems.org' +PINNER_DISABLED = ENV['INSTANA_DISABLE_GLOBAL_PINNER'] == 'true' +GLOBAL_PINNER_PATH = File.expand_path(__FILE__) +GLOBAL_PINNER_DIR = File.dirname(GLOBAL_PINNER_PATH) + +module GlobalPinner + module_function + + def install! + return if PINNER_DISABLED + return if @installed + + ensure_rubyopt_uses_absolute_path + + @installed = true + + # If Bundler is already loaded, patch it immediately + if defined?(Bundler::Dsl) + Bundler::Dsl.prepend(DslPatch) + end + + if defined?(Bundler::Injector) + Bundler::Injector.prepend(InjectorPatch) + end + + # Set up a hook to patch Bundler when it loads + setup_bundler_hook unless defined?(Bundler) + end + + def setup_bundler_hook + trace = TracePoint.new(:class) do |tp| + if tp.self.name == 'Bundler' + # Wait for Bundler::Dsl to be defined + dsl_trace = TracePoint.new(:class) do |dsl_tp| + if dsl_tp.self.name == 'Bundler::Dsl' + Bundler::Dsl.prepend(DslPatch) + dsl_trace.disable + end + end + dsl_trace.enable + + # Wait for Bundler::Injector to be defined + injector_trace = TracePoint.new(:class) do |inj_tp| + if inj_tp.self.name == 'Bundler::Injector' + Bundler::Injector.prepend(InjectorPatch) + injector_trace.disable + end + end + injector_trace.enable + + trace.disable + end + end + trace.enable + end + + def pinned_version_for(name, requirements) + versions = fetch_versions(name) + grace_cutoff = Time.now.utc - (DAYS_BACK * SECONDS_PER_DAY) + current_ruby_version = Gem::Version.new(RUBY_VERSION) + + # Filter and sort versions by created_at descending + sorted_versions = versions + .select { |v| v['created_at'] && !v['prerelease'] } + .map { |v| [v, Time.parse(v['created_at'])] } + .sort_by { |_, created_at| created_at } + .reverse + + # Find first safe version with grace period reset logic + sorted_versions.each_with_index do |(version, created_at), i| + # Skip if within grace period + next if created_at > grace_cutoff + + # Check if superseded by newer version within grace period + grace_end = created_at + (DAYS_BACK * SECONDS_PER_DAY) + superseded = sorted_versions[0...i].any? do |_, newer_date| + newer_date < grace_end + end + + next if superseded + + # Check if version satisfies requirements + number = version['number'] + next unless requirement_for(requirements).satisfied_by?(Gem::Version.new(number)) + + # Check Ruby version compatibility + ruby_requirement = version['ruby_version'] + if ruby_requirement && !Gem::Requirement.new(ruby_requirement).satisfied_by?(current_ruby_version) + next + end + + return number + end + + nil + rescue StandardError => e + warn "[Date Pinner] Failed to pin #{name}: #{e.class}: #{e.message}" + nil + end + + def fetch_versions(name) + uri = URI.parse("#{RUBYGEMS_HOST}/api/v1/versions/#{name}.json") + response = Net::HTTP.get_response(uri) + raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess) + + JSON.parse(response.body) + end + + def requirement_for(requirements) + cleaned = requirements.flatten.compact.reject { |value| value.is_a?(Hash) } + return Gem::Requirement.default if cleaned.empty? + + Gem::Requirement.new(*cleaned) + end + + module DslPatch + def gem(name, *requirements) + pinned_version = GlobalPinner.pinned_version_for(name, requirements) + + if pinned_version + puts " [Date Pinner] #{name} -> #{pinned_version}" + super(name, pinned_version) + else + super(name, *requirements) + end + end + end + + module InjectorPatch + def gem(name, *requirements) + pinned_version = GlobalPinner.pinned_version_for(name, requirements) + + if pinned_version + puts " [Date Pinner] #{name} -> #{pinned_version}" + super(name, pinned_version) + else + super(name, *requirements) + end + end + end + + def ensure_rubyopt_uses_absolute_path + rubyopt = ENV['RUBYOPT'] || '' + + relative_flag = '-r./global_pinner' + absolute_flag = "-r#{GLOBAL_PINNER_PATH}" + + # Remove any existing references to global_pinner (relative or absolute) + updated_rubyopt = rubyopt.gsub(/#{Regexp.escape(relative_flag)}|#{Regexp.escape(absolute_flag)}/, '').strip + + # Add the absolute path reference + updated_rubyopt = "#{updated_rubyopt} #{absolute_flag}".strip + + ENV['RUBYOPT'] = updated_rubyopt + end +end + +GlobalPinner.install! \ No newline at end of file