Repository: ruby/rubygems (Ruby)

Issue:
Consolidate ENV list for CI detection
We currently have two different list of environment variables for CI detection. One used by Bundler to include the CI where it's running with request headers, so that we can later on extract metrics. Another one introduced in RubyGems to be able to avoid needless call for updates messages on CI environments.

Since these lists are used for the same thing (detecting whether we are running on a CI environment), we should probably unify them and keep them in the same place.


Discussion: Some of this data is pretty out of date too. BuildKite deprecated their BUILDBOX envs a year ago, and Snap CI went under in 2017. I'll document the provenance of each of the envs while I'm in here.



Fix (patch):
diff --git a/Manifest.txt b/Manifest.txt
index ead5a06ffa3c..a2f76f875c31 100644
--- a/Manifest.txt
+++ b/Manifest.txt
@@ -20,6 +20,7 @@ bundler/lib/bundler/.document
 bundler/lib/bundler/build_metadata.rb
 bundler/lib/bundler/capistrano.rb
 bundler/lib/bundler/checksum.rb
+bundler/lib/bundler/ci_detector.rb
 bundler/lib/bundler/cli.rb
 bundler/lib/bundler/cli/add.rb
 bundler/lib/bundler/cli/binstubs.rb
@@ -348,6 +349,7 @@ lib/rubygems.rb
 lib/rubygems/available_set.rb
 lib/rubygems/basic_specification.rb
 lib/rubygems/bundler_version_finder.rb
+lib/rubygems/ci_detector.rb
 lib/rubygems/command.rb
 lib/rubygems/command_manager.rb
 lib/rubygems/commands/build_command.rb
diff --git a/bundler/lib/bundler.rb b/bundler/lib/bundler.rb
index f4f9891247a7..668c0b66607b 100644
--- a/bundler/lib/bundler.rb
+++ b/bundler/lib/bundler.rb
@@ -40,6 +40,7 @@ module Bundler
   SUDO_MUTEX = Thread::Mutex.new
 
   autoload :Checksum,               File.expand_path("bundler/checksum", __dir__)
+  autoload :CIDetector,             File.expand_path("bundler/ci_detector", __dir__)
   autoload :Definition,             File.expand_path("bundler/definition", __dir__)
   autoload :Dependency,             File.expand_path("bundler/dependency", __dir__)
   autoload :Deprecate,              File.expand_path("bundler/deprecate", __dir__)
diff --git a/bundler/lib/bundler/ci_detector.rb b/bundler/lib/bundler/ci_detector.rb
new file mode 100644
index 000000000000..e5fedbdea8d3
--- /dev/null
+++ b/bundler/lib/bundler/ci_detector.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+module Bundler
+  module CIDetector
+    # NOTE: Any changes made here will need to be made to both lib/rubygems/ci_detector.rb and
+    # bundler/lib/bundler/ci_detector.rb (which are enforced duplicates).
+    # TODO: Drop that duplication once bundler drops support for RubyGems 3.4
+    #
+    # ## Recognized CI providers, their signifiers, and the relevant docs ##
+    #
+    # Travis CI   - CI, TRAVIS            https://docs.travis-ci.com/user/environment-variables/#default-environment-variables
+    # Cirrus CI   - CI, CIRRUS_CI         https://cirrus-ci.org/guide/writing-tasks/#environment-variables
+    # Circle CI   - CI, CIRCLECI          https://circleci.com/docs/variables/#built-in-environment-variables
+    # Gitlab CI   - CI, GITLAB_CI         https://docs.gitlab.com/ee/ci/variables/
+    # AppVeyor    - CI, APPVEYOR          https://www.appveyor.com/docs/environment-variables/
+    # CodeShip    - CI_NAME               https://docs.cloudbees.com/docs/cloudbees-codeship/latest/pro-builds-and-configuration/environment-variables#_default_environment_variables
+    # dsari       - CI, DSARI             https://github.com/rfinnie/dsari#running
+    # Jenkins     - BUILD_NUMBER          https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables
+    # TeamCity    - TEAMCITY_VERSION      https://www.jetbrains.com/help/teamcity/predefined-build-parameters.html#Predefined+Server+Build+Parameters
+    # Appflow     - CI_BUILD_ID           https://ionic.io/docs/appflow/automation/environments#predefined-environments
+    # TaskCluster - TASKCLUSTER_ROOT_URL  https://docs.taskcluster.net/docs/manual/design/env-vars
+    # Semaphore   - CI, SEMAPHORE         https://docs.semaphoreci.com/ci-cd-environment/environment-variables/
+    # BuildKite   - CI, BUILDKITE         https://buildkite.com/docs/pipelines/environment-variables
+    # GoCD        - GO_SERVER_URL         https://docs.gocd.org/current/faq/dev_use_current_revision_in_build.html
+    # GH Actions  - CI, GITHUB_ACTIONS    https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables
+    #
+    # ### Some "standard" ENVs that multiple providers may set ###
+    #
+    # * CI - this is set by _most_ (but not all) CI providers now; it's approaching a standard.
+    # * CI_NAME - Not as frequently used, but some providers set this to specify their own name
+
+    # Any of these being set is a reasonably reliable indicator that we are
+    # executing in a CI environment.
+    ENV_INDICATORS = [
+      "CI",
+      "CI_NAME",
+      "CONTINUOUS_INTEGRATION",
+      "BUILD_NUMBER",
+      "CI_APP_ID",
+      "CI_BUILD_ID",
+      "CI_BUILD_NUMBER",
+      "RUN_ID",
+      "TASKCLUSTER_ROOT_URL",
+    ].freeze
+
+    # For each CI, this env suffices to indicate that we're on _that_ CI's
+    # containers. (A few of them only supply a CI_NAME variable, which is also
+    # nice). And if they set "CI" but we can't tell which one they are, we also
+    # want to know that - a bare "ci" without another token tells us as much.
+    ENV_DESCRIPTORS = {
+      "TRAVIS" => "travis",
+      "CIRCLECI" => "circle",
+      "CIRRUS_CI" => "cirrus",
+      "DSARI" => "dsari",
+      "SEMAPHORE" => "semaphore",
+      "JENKINS_URL" => "jenkins",
+      "BUILDKITE" => "buildkite",
+      "GO_SERVER_URL" => "go",
+      "GITLAB_CI" => "gitlab",
+      "GITHUB_ACTIONS" => "github",
+      "TASKCLUSTER_ROOT_URL" => "taskcluster",
+      "CI" => "ci",
+    }.freeze
+
+    def self.ci?
+      ENV_INDICATORS.any? {|var| ENV.include?(var) }
+    end
+
+    def self.ci_strings
+      matching_names = ENV_DESCRIPTORS.select {|env, _| ENV[env] }.values
+      matching_names << ENV["CI_NAME"].downcase if ENV["CI_NAME"]
+      matching_names.reject(&:empty?).sort.uniq
+    end
+  end
+end
diff --git a/bundler/lib/bundler/fetcher.rb b/bundler/lib/bundler/fetcher.rb
index dd91b7ceb437..27d931da4d73 100644
--- a/bundler/lib/bundler/fetcher.rb
+++ b/bundler/lib/bundler/fetcher.rb
@@ -95,6 +95,7 @@ class << self
     self.max_retries    = Bundler.settings[:retry] # How many retries for the API call
 
     def initialize(remote)
+      @cis = nil
       @remote = remote
 
       Socket.do_not_reverse_lookup = true
@@ -242,20 +243,7 @@ def fetch_specs(gem_names)
     end
 
     def cis
-      env_cis = {
-        "TRAVIS" => "travis",
-        "CIRCLECI" => "circle",
-        "SEMAPHORE" => "semaphore",
-        "JENKINS_URL" => "jenkins",
-        "BUILDBOX" => "buildbox",
-        "GO_SERVER_URL" => "go",
-        "SNAP_CI" => "snap",
-        "GITLAB_CI" => "gitlab",
-        "GITHUB_ACTIONS" => "github",
-        "CI_NAME" => ENV["CI_NAME"],
-        "CI" => "ci",
-      }
-      env_cis.find_all {|env, _| ENV[env] }.map {|_, ci| ci }
+      @cis ||= Bundler::CIDetector.ci_strings
     end
 
     def connection
diff --git a/bundler/spec/bundler/ci_detector_spec.rb b/bundler/spec/bundler/ci_detector_spec.rb
new file mode 100644
index 000000000000..299d8005e8fe
--- /dev/null
+++ b/bundler/spec/bundler/ci_detector_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+RSpec.describe Bundler::CIDetector do
+  # This is properly tested in rubygems, under the name Gem::CIDetector
+  # But the test that confirms that our version _stays in sync_ with that version
+  # will live here.
+
+  it "stays in sync with the rubygems implementation" do
+    rubygems_implementation_path = File.join(git_root, "lib", "rubygems", "ci_detector.rb")
+    expect(File.exist?(rubygems_implementation_path)).to be_truthy
+    rubygems_code = File.read(rubygems_implementation_path)
+    denamespaced_rubygems_code = rubygems_code.sub("Gem", "NAMESPACE")
+
+    bundler_implementation_path = File.join(source_lib_dir, "bundler", "ci_detector.rb")
+    expect(File.exist?(bundler_implementation_path)).to be_truthy
+    bundler_code = File.read(bundler_implementation_path)
+    denamespaced_bundler_code = bundler_code.sub("Bundler", "NAMESPACE")
+
+    expect(denamespaced_bundler_code).to eq(denamespaced_rubygems_code)
+  end
+end
diff --git a/bundler/spec/bundler/fetcher_spec.rb b/bundler/spec/bundler/fetcher_spec.rb
index 92790dc7d6f7..f764c44faeec 100644
--- a/bundler/spec/bundler/fetcher_spec.rb
+++ b/bundler/spec/bundler/fetcher_spec.rb
@@ -143,18 +143,20 @@
 
     describe "include CI information" do
       it "from one CI" do
-        with_env_vars("JENKINS_URL" => "foo") do
+        with_env_vars("CI" => nil, "JENKINS_URL" => "foo") do
           ci_part = fetcher.user_agent.split(" ").find {|x| x.start_with?("ci/") }
-          expect(ci_part).to match("jenkins")
+          cis = ci_part.split("/").last.split(",")
+          expect(cis).to include("jenkins")
+          expect(cis).not_to include("ci")
         end
       end
 
       it "from many CI" do
-        with_env_vars("TRAVIS" => "foo", "GITLAB_CI" => "gitlab", "CI_NAME" => "my_ci") do
+        with_env_vars("CI" => "true", "SEMAPHORE" => nil, "TRAVIS" => "foo", "GITLAB_CI" => "gitlab", "CI_NAME" => "MY_ci") do
           ci_part = fetcher.user_agent.split(" ").find {|x| x.start_with?("ci/") }
-          expect(ci_part).to match("travis")
-          expect(ci_part).to match("gitlab")
-          expect(ci_part).to match("my_ci")
+          cis = ci_part.split("/").last.split(",")
+          expect(cis).to include("ci", "gitlab", "my_ci", "travis")
+          expect(cis).not_to include("semaphore")
         end
       end
     end
diff --git a/lib/rubygems.rb b/lib/rubygems.rb
index d89cc4e4cbbe..f773b44f8702 100644
--- a/lib/rubygems.rb
+++ b/lib/rubygems.rb
@@ -1290,6 +1290,7 @@ def default_gem_load_paths
   MARSHAL_SPEC_DIR = "quick/Marshal.#{Gem.marshal_version}/".freeze
 
   autoload :ConfigFile,         File.expand_path("rubygems/config_file", __dir__)
+  autoload :CIDetector,         File.expand_path("rubygems/ci_detector", __dir__)
   autoload :Dependency,         File.expand_path("rubygems/dependency", __dir__)
   autoload :DependencyList,     File.expand_path("rubygems/dependency_list", __dir__)
   autoload :Installer,          File.expand_path("rubygems/installer", __dir__)
diff --git a/lib/rubygems/ci_detector.rb b/lib/rubygems/ci_detector.rb
new file mode 100644
index 000000000000..7a2d4ee29a93
--- /dev/null
+++ b/lib/rubygems/ci_detector.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+module Gem
+  module CIDetector
+    # NOTE: Any changes made here will need to be made to both lib/rubygems/ci_detector.rb and
+    # bundler/lib/bundler/ci_detector.rb (which are enforced duplicates).
+    # TODO: Drop that duplication once bundler drops support for RubyGems 3.4
+    #
+    # ## Recognized CI providers, their signifiers, and the relevant docs ##
+    #
+    # Travis CI   - CI, TRAVIS            https://docs.travis-ci.com/user/environment-variables/#default-environment-variables
+    # Cirrus CI   - CI, CIRRUS_CI         https://cirrus-ci.org/guide/writing-tasks/#environment-variables
+    # Circle CI   - CI, CIRCLECI          https://circleci.com/docs/variables/#built-in-environment-variables
+    # Gitlab CI   - CI, GITLAB_CI         https://docs.gitlab.com/ee/ci/variables/
+    # AppVeyor    - CI, APPVEYOR          https://www.appveyor.com/docs/environment-variables/
+    # CodeShip    - CI_NAME               https://docs.cloudbees.com/docs/cloudbees-codeship/latest/pro-builds-and-configuration/environment-variables#_default_environment_variables
+    # dsari       - CI, DSARI             https://github.com/rfinnie/dsari#running
+    # Jenkins     - BUILD_NUMBER          https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables
+    # TeamCity    - TEAMCITY_VERSION      https://www.jetbrains.com/help/teamcity/predefined-build-parameters.html#Predefined+Server+Build+Parameters
+    # Appflow     - CI_BUILD_ID           https://ionic.io/docs/appflow/automation/environments#predefined-environments
+    # TaskCluster - TASKCLUSTER_ROOT_URL  https://docs.taskcluster.net/docs/manual/design/env-vars
+    # Semaphore   - CI, SEMAPHORE         https://docs.semaphoreci.com/ci-cd-environment/environment-variables/
+    # BuildKite   - CI, BUILDKITE         https://buildkite.com/docs/pipelines/environment-variables
+    # GoCD        - GO_SERVER_URL         https://docs.gocd.org/current/faq/dev_use_current_revision_in_build.html
+    # GH Actions  - CI, GITHUB_ACTIONS    https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables
+    #
+    # ### Some "standard" ENVs that multiple providers may set ###
+    #
+    # * CI - this is set by _most_ (but not all) CI providers now; it's approaching a standard.
+    # * CI_NAME - Not as frequently used, but some providers set this to specify their own name
+
+    # Any of these being set is a reasonably reliable indicator that we are
+    # executing in a CI environment.
+    ENV_INDICATORS = [
+      "CI",
+      "CI_NAME",
+      "CONTINUOUS_INTEGRATION",
+      "BUILD_NUMBER",
+      "CI_APP_ID",
+      "CI_BUILD_ID",
+      "CI_BUILD_NUMBER",
+      "RUN_ID",
+      "TASKCLUSTER_ROOT_URL",
+    ].freeze
+
+    # For each CI, this env suffices to indicate that we're on _that_ CI's
+    # containers. (A few of them only supply a CI_NAME variable, which is also
+    # nice). And if they set "CI" but we can't tell which one they are, we also
+    # want to know that - a bare "ci" without another token tells us as much.
+    ENV_DESCRIPTORS = {
+      "TRAVIS" => "travis",
+      "CIRCLECI" => "circle",
+      "CIRRUS_CI" => "cirrus",
+      "DSARI" => "dsari",
+      "SEMAPHORE" => "semaphore",
+      "JENKINS_URL" => "jenkins",
+      "BUILDKITE" => "buildkite",
+      "GO_SERVER_URL" => "go",
+      "GITLAB_CI" => "gitlab",
+      "GITHUB_ACTIONS" => "github",
+      "TASKCLUSTER_ROOT_URL" => "taskcluster",
+      "CI" => "ci",
+    }.freeze
+
+    def self.ci?
+      ENV_INDICATORS.any? {|var| ENV.include?(var) }
+    end
+
+    def self.ci_strings
+      matching_names = ENV_DESCRIPTORS.select {|env, _| ENV[env] }.values
+      matching_names << ENV["CI_NAME"].downcase if ENV["CI_NAME"]
+      matching_names.reject(&:empty?).sort.uniq
+    end
+  end
+end
diff --git a/lib/rubygems/update_suggestion.rb b/lib/rubygems/update_suggestion.rb
index af7067f9b384..6f3ec5f49367 100644
--- a/lib/rubygems/update_suggestion.rb
+++ b/lib/rubygems/update_suggestion.rb
@@ -4,15 +4,6 @@
 # Mixin methods for Gem::Command to promote available RubyGems update
 
 module Gem::UpdateSuggestion
-  # list taken from https://github.com/watson/ci-info/blob/7a3c30d/index.js#L56-L66
-  CI_ENV_VARS = [
-    "CI", # Travis CI, CircleCI, Cirrus CI, Gitlab CI, Appveyor, CodeShip, dsari
-    "CONTINUOUS_INTEGRATION", # Travis CI, Cirrus CI
-    "BUILD_NUMBER", # Jenkins, TeamCity
-    "CI_APP_ID", "CI_BUILD_ID", "CI_BUILD_NUMBER", # Applfow
-    "RUN_ID" # TaskCluster, dsari
-  ].freeze
-
   ONE_WEEK = 7 * 24 * 60 * 60
 
   ##
@@ -39,7 +30,7 @@ def eligible_for_update?
     return false unless Gem.ui.tty?
     return false if Gem.rubygems_version.prerelease?
     return false if Gem.disable_system_update_message
-    return false if ci?
+    return false if Gem::CIDetector.ci?
 
     # check makes sense only when we can store timestamp of last try
     # otherwise we will not be able to prevent "annoying" update message
@@ -62,8 +53,4 @@ def eligible_for_update?
   rescue StandardError # don't block install command on any problem
     false
   end
-
-  def ci?
-    CI_ENV_VARS.any? {|var| ENV.include?(var) }
-  end
 end