Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d11fbc5
Add the Gem::CredentialStore wrapper
hsbt Jul 2, 2026
cd7d433
Add the macOS Keychain backend
hsbt Jul 2, 2026
325b71a
Add the Linux Secret Service backend
hsbt Jul 2, 2026
a9b11c4
Add the Windows Credential Manager backend
hsbt Jul 2, 2026
25f421c
Store gem credentials through the credential store in Gem::ConfigFile
hsbt Jul 2, 2026
acf31bd
Read the gem API key from the credential store when enabled
hsbt Jul 2, 2026
3891def
Clear the credential store on gem signout
hsbt Jul 2, 2026
a09c85b
Note credential store use in gem signin and push help
hsbt Jul 2, 2026
eb79efd
Store bundler host credentials in the credential store when enabled
hsbt Jul 2, 2026
01381e0
Document the credential_store setting in bundle-config(1)
hsbt Jul 2, 2026
9d09445
Support pluggable credential store backends by name
hsbt Jul 2, 2026
14c04f9
Accept a credential store backend name in Gem::ConfigFile
hsbt Jul 2, 2026
aa22e6c
Accept a credential store backend name in bundler settings
hsbt Jul 2, 2026
9ee2fee
Document selecting a credential store backend by name
hsbt Jul 2, 2026
ae5ecd0
Move the native backends under credential_store/native
hsbt Jul 2, 2026
20a4bd5
Normalize credential_store keys like the bundler config file
hsbt Jul 2, 2026
18ae9c7
Fall back when the paired RubyGems has no credential store
hsbt Jul 2, 2026
581dbbc
Spell out what gem signout removes from the credential store
hsbt Jul 2, 2026
54016d2
Reject macOS keychain secrets the security CLI cannot round-trip
hsbt Jul 2, 2026
4b8c38f
Keep the config file and the credential store consistent on write
hsbt Jul 2, 2026
b4dff4b
Explain credential store selection, migration, and API key precedence
hsbt Jul 2, 2026
abcd8a6
Clear every registry from the credential store on gem signout
hsbt Jul 2, 2026
c743eb1
Fix credential store tests under ruby-core and JRuby
hsbt Jul 2, 2026
40a6d0b
Select the bundler credential store per host
hsbt Aug 24, 2026
bc8f204
Document per-host credential_store selection in bundle-config(1)
hsbt Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Manifest.txt
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,10 @@ lib/rubygems/core_ext/kernel_gem.rb
lib/rubygems/core_ext/kernel_require.rb
lib/rubygems/core_ext/kernel_warn.rb
lib/rubygems/core_ext/tcpsocket_init.rb
lib/rubygems/credential_store.rb
lib/rubygems/credential_store/native/linux.rb
lib/rubygems/credential_store/native/macos.rb
lib/rubygems/credential_store/native/windows.rb
lib/rubygems/defaults.rb
lib/rubygems/dependency.rb
lib/rubygems/dependency_installer.rb
Expand Down
4 changes: 4 additions & 0 deletions lib/bundler/man/bundle-config.1
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ Cooldown filtering depends on the gem server providing a per\-version \fBcreated
.IP
A \fBcreated_at\fR timestamp is read as UTC when it carries no time zone offset\. \fBrubygems\.org\fR always sends one, but a third\-party server that omits it would otherwise shift the cooldown window by the offset of whatever machine runs bundler\.
.IP "\(bu" 4
\fBcredential_store\fR (\fBBUNDLE_CREDENTIAL_STORE\fR): Experimental: store and read host credentials (the values otherwise set via \fBbundle config set <host> <user:pass>\fR) in a credential store instead of the plain text config file\. Set it to \fBtrue\fR to use the operating system's native store (macOS Keychain, Linux Secret Service, Windows Credential Manager) when one is available on this platform, or to the name of a backend provided by a third\-party gem, such as \fB1password\fR\. Falls back to the config file when the store is unavailable or fails, warning that the credential was written in plain text\. Defaults to false\. Credentials already written to the config file are not migrated automatically; re\-run \fBbundle config set <host> <user:pass>\fR with the setting enabled to move each one into the store\. Being experimental, the name and behavior of this setting may change in a future release\.
.IP
The store can also be selected per host with \fBcredential_store\.<host>\fR (\fBBUNDLE_CREDENTIAL_STORE__<HOST>\fR), for example \fBbundle config set credential_store\.gems\.example\.com code_artifact\fR\. That host then uses only the named backend, with no chaining to the global one, while every other host keeps following the global setting\. Setting a host's value to \fBfalse\fR keeps that one host on the config file even when a global store is enabled\.
.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\.
Expand Down
21 changes: 21 additions & 0 deletions lib/bundler/man/bundle-config.1.ronn
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,27 @@ learn more about their operation in [bundle install(1)](bundle-install.1.html).
offset. `rubygems.org` always sends one, but a third-party server
that omits it would otherwise shift the cooldown window by the
offset of whatever machine runs bundler.
* `credential_store` (`BUNDLE_CREDENTIAL_STORE`):
Experimental: store and read host credentials (the values otherwise set
via `bundle config set <host> <user:pass>`) in a credential store instead
of the plain text config file. Set it to `true` to use the operating
system's native store (macOS Keychain, Linux Secret Service, Windows
Credential Manager) when one is available on this platform, or to the name
of a backend provided by a third-party gem, such as `1password`. Falls
back to the config file when the store is unavailable or fails, warning
that the credential was written in plain text. Defaults to false.
Credentials already written to the config file are not migrated
automatically; re-run `bundle config set <host> <user:pass>` with the
setting enabled to move each one into the store. Being experimental, the
name and behavior of this setting may change in a future release.

The store can also be selected per host with `credential_store.<host>`
(`BUNDLE_CREDENTIAL_STORE__<HOST>`), for example `bundle config set
credential_store.gems.example.com code_artifact`. That host then uses
only the named backend, with no chaining to the global one, while every
other host keeps following the global setting. Setting a host's value
to `false` keeps that one host on the config file even when a global
store is enabled.
* `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
Expand Down
117 changes: 113 additions & 4 deletions lib/bundler/settings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ class Settings
bin
cache_path
console
credential_store
default_cli_command
gem.ci
gem.github_username
Expand Down Expand Up @@ -185,7 +186,7 @@ def mirror_for(uri)
end

def credentials_for(uri)
self[uri.to_s] || self[uri.host]
credentials_from_store(uri) || self[uri.to_s] || self[uri.host]
end

def gem_mirrors
Expand Down Expand Up @@ -355,7 +356,7 @@ def is_bool(name)

def is_string(name)
name = self.class.key_to_s(name)
STRING_KEYS.include?(name) || name.start_with?("local.") || name.start_with?("mirror.") || name.start_with?("build.")
STRING_KEYS.include?(name) || name.start_with?("local.") || name.start_with?("mirror.") || name.start_with?("build.") || name.start_with?("credential_store.")
end

def to_bool(value)
Expand Down Expand Up @@ -385,6 +386,100 @@ def is_userinfo(value)
value.include?(":")
end

##
# The Gem::CredentialStore instance to use, or nil when the
# `credential_store` setting is off. The value is `true`/`"true"` for this
# platform's native backend or a backend name such as `"1password"`.
# Guarded by a cheap lookup so reading and writing settings costs nothing
# extra when the setting is disabled.

# The account namespace Bundler uses in the shared native store, kept
# separate from RubyGems so that gem signout does not remove Bundler's
# host credentials.
CREDENTIAL_STORE_SERVICE = "bundler"

def active_credential_store(host = nil)
spec = credential_store_spec(host)
return nil unless spec

store_class = credential_store_class
return nil unless store_class

store_class.for(spec, service: CREDENTIAL_STORE_SERVICE)
end

# A `credential_store.<host>` setting overrides the global
# `credential_store` for that host only, so one source can use a
# dedicated backend (say, a CodeArtifact token issuer) while every other
# host keeps the global choice. There is no chain between backends: an
# explicit `false` for a host keeps that host on the config file even
# when a global store is enabled.
def credential_store_spec(host = nil)
value = self["credential_store.#{host}"] if host
value = self[:credential_store] if value.nil?

case value
when nil, "", "false", false then nil
when "true", true then true
else value.to_s
end
end

# The Gem::CredentialStore class, or nil when the paired RubyGems is too
# old to ship one. In that case the setting is honored as a no-op with a
# one-time warning so a bundle keeps using the config file instead of
# raising. Bundler can run on an older RubyGems than it was released with.
def credential_store_class
return @credential_store_class if defined?(@credential_store_class)

@credential_store_class =
begin
require "rubygems/credential_store"
Gem::CredentialStore if Gem::CredentialStore.respond_to?(:for)
rescue LoadError
nil
end

if @credential_store_class.nil?
Bundler.ui.warn "The `credential_store` setting is set but this RubyGems does not provide a credential store. Falling back to the Bundler config file."
end

@credential_store_class
end

##
# True for keys that aren't reserved for a known bool/number/array/
# string setting (or the gem.push_key signing key path), i.e. keys
# that are eligible to be a host credential like the ones set via
# `bundle config set gems.example.com user:pass`. This mirrors the
# heuristic #printable_value already uses to decide whether to redact
# a value for display.

def credential_store_key?(raw_key)
!(is_bool(raw_key) || is_num(raw_key) || is_array(raw_key) || is_string(raw_key) || is_credential(raw_key))
end

# A credential key is either a bare host ("gems.example.com") or a full
# URL ("https://gems.example.com/"); per-host store selection wants the
# host either way.
def credential_host(raw_key)
return raw_key unless raw_key.start_with?("http://", "https://")

require_relative "vendored_uri"
Gem::URI(raw_key).host || raw_key
rescue Gem::URI::Error
raw_key
end

def credentials_from_store(uri)
return nil unless store = active_credential_store(uri.host)

# Normalize with key_for so a value stored under, say,
# "https://host/" is found when the source URI is "https://host",
# exactly as the config-file path already matches via self[].
store.get(key_for(uri.to_s)) || store.get(key_for(uri.host))
end

def to_array(value)
return [] unless value
value.tr(" ", ":").split(":").map(&:to_sym)
Expand All @@ -398,10 +493,24 @@ def array_to_s(array)

def set_key(raw_key, value, hash, file)
raw_key = self.class.key_to_s(raw_key)
value = array_to_s(value) if is_array(raw_key)

key = key_for(raw_key)

if credential_store_key?(raw_key) && (store = active_credential_store(credential_host(raw_key)))
if value.nil?
store.delete(key)
elsif value.is_a?(String) && is_userinfo(value)
if store.set(key, value)
# Stored in the credential store, so drop any plaintext copy left
# in this config file by falling through with a nil value.
value = nil
else
Bundler.ui.warn "Could not write the credential to the credential store, so it was written to the Bundler config file in plain text."
end
end
end

value = array_to_s(value) if is_array(raw_key)

return if hash[key] == value

hash[key] = value
Expand Down
4 changes: 3 additions & 1 deletion lib/rubygems/commands/push_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ def description # :nodoc:
The gem can be removed from the index and deleted from the server using the yank
command. For further discussion see the help for the yank command.

The push command will use ~/.gem/credentials to authenticate to a server, but you can use the RubyGems environment variable GEM_HOST_API_KEY to set the api key to authenticate.
The push command will use ~/.gem/credentials to authenticate to a server, but you can use the RubyGems environment variable GEM_HOST_API_KEY to set the api key to authenticate. If the :credential_store: gemrc option (or RUBYGEMS_CREDENTIAL_STORE environment variable) is set, the API key is stored in and read from the credential store it selects instead of ~/.gem/credentials.

The API key to send is resolved in this order: the GEM_HOST_API_KEY environment variable, the --key option, the credential store (when :credential_store: is set), then ~/.gem/credentials. The first one found is used.
EOF
end

Expand Down
4 changes: 3 additions & 1 deletion lib/rubygems/commands/signin_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ def description # :nodoc:
"The signin command executes host sign in for a push server (the default is"\
" https://rubygems.org). The host can be provided with the host flag or can"\
" be inferred from the provided gem. Host resolution matches the resolution"\
" strategy for the push command."
" strategy for the push command. If the :credential_store: gemrc option (or"\
" RUBYGEMS_CREDENTIAL_STORE environment variable) is set, the resulting API key is"\
" stored in the credential store it selects instead of ~/.gem/credentials."
end

def usage # :nodoc:
Expand Down
12 changes: 8 additions & 4 deletions lib/rubygems/commands/signout_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ def initialize

def description # :nodoc:
"The `signout` command is used to sign out from all current sessions,"\
" allowing you to sign in using a different set of credentials."
" allowing you to sign in using a different set of credentials. It removes"\
" the ~/.gem/credentials file. If the :credential_store: gemrc option is"\
" set, it also removes every RubyGems key from the credential store,"\
" including keys saved for other hosts with `gem signin --host`."
end

def usage # :nodoc:
Expand All @@ -18,15 +21,16 @@ def usage # :nodoc:

def execute
credentials_path = Gem.configuration.credentials_path
credentials_file_exists = File.exist?(credentials_path)

if !File.exist?(credentials_path)
if !credentials_file_exists && !Gem.configuration.credential_store
alert_error "You are not currently signed in."
elsif !File.writable?(credentials_path)
elsif credentials_file_exists && !File.writable?(credentials_path)
alert_error "File '#{Gem.configuration.credentials_path}' is read-only."\
" Please make sure it is writable."
else
Gem.configuration.unset_api_key!
say "You have successfully signed out from all sessions."
say "You have successfully signed out of every registry, including RubyGems.org."
end
end
end
Loading