rake-gem-maintenance 0.1.7 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CLAUDE.md +7 -7
- data/Gemfile.lock +2 -2
- data/README.md +9 -9
- data/TODO.md +4 -1
- data/lib/rake/gem/maintenance/api_key_renewer.rb +37 -35
- data/lib/rake/gem/maintenance/ci_environment.rb +7 -5
- data/lib/rake/gem/maintenance/credential_store.rb +46 -44
- data/lib/rake/gem/maintenance/gem_publisher.rb +86 -84
- data/lib/rake/gem/maintenance/gem_push.rb +44 -42
- data/lib/rake/gem/maintenance/install_tasks.rb +5 -5
- data/lib/rake/gem/maintenance/otp_provider.rb +33 -31
- data/lib/rake/gem/maintenance/renew_api_key_task.rb +111 -109
- data/lib/rake/gem/maintenance/repos.rb +81 -79
- data/lib/rake/gem/maintenance/ruby_gems_api_key_creator.rb +39 -37
- data/lib/rake/gem/maintenance/upgrade_task.rb +245 -242
- data/lib/rake/gem/maintenance/version.rb +4 -2
- data/lib/rake/gem/maintenance/version_bump_task.rb +83 -81
- data/lib/rake/gem/maintenance/woodpecker_secret_store.rb +69 -67
- data/scripts/ci_publish_rubygems.rb +3 -3
- metadata +1 -1
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../maintenance"
|
|
4
4
|
|
|
5
|
-
Rake::
|
|
6
|
-
Rake::
|
|
5
|
+
Rake::Gem::Maintenance::Repos.rubygems_api_key_env_var = "GEM_HOST_API_KEY"
|
|
6
|
+
Rake::Gem::Maintenance::Repos.rubygems_otp_seed_env_var = "RUBYGEMS_OTP_SEED"
|
|
7
7
|
|
|
8
|
-
Rake::
|
|
9
|
-
Rake::
|
|
8
|
+
Rake::Gem::Maintenance::UpgradeTask.new
|
|
9
|
+
Rake::Gem::Maintenance::VersionBumpTask.new
|
|
10
10
|
|
|
11
|
-
Rake::
|
|
11
|
+
Rake::Gem::Maintenance::CredentialStore.new.apply_to_env(
|
|
12
12
|
username_env_var: "RUBYGEMS_USERNAME",
|
|
13
13
|
api_key_env_var: "GEM_HOST_API_KEY"
|
|
14
14
|
)
|
|
@@ -1,44 +1,46 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Rake
|
|
4
|
-
module
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
4
|
+
module Gem
|
|
5
|
+
module Maintenance
|
|
6
|
+
# Resolves a 2FA OTP code for gem push, either from the environment or interactively.
|
|
7
|
+
# Resolution order for otp_for:
|
|
8
|
+
# 1. RUBYGEMS_OTP env var set → use raw code (works in CI and locally)
|
|
9
|
+
# 2. otp_seed_env_var provided and env var set → generate TOTP code (works in CI and locally)
|
|
10
|
+
# 3. CI environment → nil (gate only interactive prompt)
|
|
11
|
+
# 4. Interactive prompt
|
|
12
|
+
class OtpProvider
|
|
13
|
+
def initialize(ci_environment: CIEnvironment, input: $stdin)
|
|
14
|
+
@ci_environment = ci_environment
|
|
15
|
+
@input = input
|
|
16
|
+
end
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
def otp_for(repository_name, otp_seed_env_var: nil)
|
|
19
|
+
env_otp = ENV.fetch("RUBYGEMS_OTP", nil)
|
|
20
|
+
return env_otp if env_otp && !env_otp.empty?
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
if otp_seed_env_var
|
|
23
|
+
seed = ENV.fetch(otp_seed_env_var, nil)
|
|
24
|
+
return generate_totp(seed) if seed && !seed.empty?
|
|
25
|
+
end
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
return nil if @ci_environment.ci?
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
prompt_for_otp(repository_name)
|
|
30
|
+
end
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
private
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
def generate_totp(seed)
|
|
35
|
+
require "rotp"
|
|
36
|
+
::ROTP::TOTP.new(seed).now
|
|
37
|
+
end
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
def prompt_for_otp(repository_name)
|
|
40
|
+
print "Enter OTP for #{repository_name} (blank to skip): "
|
|
41
|
+
value = @input.gets&.chomp
|
|
42
|
+
value&.empty? ? nil : value
|
|
43
|
+
end
|
|
42
44
|
end
|
|
43
45
|
end
|
|
44
46
|
end
|
|
@@ -5,134 +5,136 @@ require "rake/tasklib"
|
|
|
5
5
|
require_relative "credential_store"
|
|
6
6
|
|
|
7
7
|
module Rake
|
|
8
|
-
module
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
8
|
+
module Gem
|
|
9
|
+
module Maintenance
|
|
10
|
+
# Generates a new rubygems.org API key via the rubygems.org API and stores
|
|
11
|
+
# it in a Woodpecker CI org-level secret. Intended for local developer use only.
|
|
12
|
+
#
|
|
13
|
+
# Creates: <namespace>:renew_api_key
|
|
14
|
+
#
|
|
15
|
+
# Reads WOODPECKER_SERVER and WOODPECKER_TOKEN (or ~/.config/woodpecker/token)
|
|
16
|
+
# from the environment.
|
|
17
|
+
class RenewApiKeyTask < ::Rake::TaskLib
|
|
18
|
+
attr_accessor :namespace_name, :host, :api_key_env_var, :ci_environment,
|
|
19
|
+
:woodpecker_server, :woodpecker_org, :woodpecker_secret_name,
|
|
20
|
+
:username_env_var, :password_env_var, :credential_store
|
|
21
|
+
|
|
22
|
+
def initialize(namespace_name = :upgrade)
|
|
23
|
+
super()
|
|
24
|
+
apply_defaults(namespace_name)
|
|
25
|
+
define_tasks
|
|
26
|
+
end
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def apply_defaults(namespace_name)
|
|
31
|
+
@namespace_name = namespace_name
|
|
32
|
+
@host = "https://rubygems.org"
|
|
33
|
+
@api_key_env_var = "GEM_HOST_API_KEY"
|
|
34
|
+
@ci_environment = CIEnvironment
|
|
35
|
+
@woodpecker_server = ENV.fetch("WOODPECKER_SERVER", nil)
|
|
36
|
+
@woodpecker_org = ENV.fetch("WOODPECKER_ORG", "cbp-org")
|
|
37
|
+
@woodpecker_secret_name = "rubygems_api_key"
|
|
38
|
+
@username_env_var = "RUBYGEMS_USERNAME"
|
|
39
|
+
@password_env_var = "RUBYGEMS_PASSWORD"
|
|
40
|
+
@credential_store = CredentialStore.new
|
|
41
|
+
end
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
def define_tasks
|
|
44
|
+
task_instance = self
|
|
45
|
+
namespace namespace_name do
|
|
46
|
+
desc "Generate a new rubygems.org API key and store it in Woodpecker CI"
|
|
47
|
+
task(:renew_api_key) { task_instance.send(:run_renewal) }
|
|
48
|
+
end
|
|
47
49
|
end
|
|
48
|
-
end
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
51
|
+
def run_renewal
|
|
52
|
+
credential_store.apply_to_env(username_env_var: username_env_var, api_key_env_var: api_key_env_var)
|
|
53
|
+
abort_if_ci
|
|
54
|
+
username, password = prompt_credentials
|
|
55
|
+
prompt_otp_seed_if_missing
|
|
56
|
+
api_key = generate_api_key(username, password)
|
|
57
|
+
save_and_distribute(username, api_key)
|
|
58
|
+
end
|
|
58
59
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
def generate_api_key(username, password)
|
|
61
|
+
otp = OtpProvider.new.otp_for("rubygems", otp_seed_env_var: "RUBYGEMS_OTP_SEED")
|
|
62
|
+
RubyGemsApiKeyCreator.new(host: host).create(username, password, otp: otp)
|
|
63
|
+
end
|
|
63
64
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
def save_and_distribute(username, api_key)
|
|
66
|
+
puts "\n[INFO] New API key generated."
|
|
67
|
+
credential_store.update(username: username, api_key: api_key, api_key_env_var: api_key_env_var)
|
|
68
|
+
store_in_woodpecker(api_key)
|
|
69
|
+
end
|
|
69
70
|
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
def abort_if_ci
|
|
72
|
+
return unless ci_environment.ci?
|
|
72
73
|
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
missing = [username_env_var, password_env_var].select { |v| env_credential(v).nil? }
|
|
75
|
+
return if missing.empty?
|
|
75
76
|
|
|
76
|
-
|
|
77
|
-
|
|
77
|
+
abort "[ERROR] Set #{missing.join(' and ')} CI secrets to run renewal unattended."
|
|
78
|
+
end
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
def prompt_otp_seed_if_missing
|
|
81
|
+
return if (seed = ENV.fetch("RUBYGEMS_OTP_SEED", nil)) && !seed.empty?
|
|
81
82
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def prompt_credentials
|
|
88
|
-
username = env_credential(username_env_var) || prompt_username
|
|
89
|
-
password = env_credential(password_env_var) || read_password("rubygems.org password: ")
|
|
90
|
-
[username, password]
|
|
91
|
-
end
|
|
83
|
+
print "rubygems.org OTP seed (TOTP secret, not a code): "
|
|
84
|
+
seed = $stdin.gets&.chomp
|
|
85
|
+
ENV["RUBYGEMS_OTP_SEED"] = seed if seed && !seed.empty?
|
|
86
|
+
end
|
|
92
87
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
88
|
+
def prompt_credentials
|
|
89
|
+
username = env_credential(username_env_var) || prompt_username
|
|
90
|
+
password = env_credential(password_env_var) || read_password("rubygems.org password: ")
|
|
91
|
+
[username, password]
|
|
92
|
+
end
|
|
97
93
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
value
|
|
103
|
-
end
|
|
94
|
+
def env_credential(env_var)
|
|
95
|
+
value = ENV.fetch(env_var, nil)
|
|
96
|
+
value&.empty? ? nil : value
|
|
97
|
+
end
|
|
104
98
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
rescue LoadError
|
|
112
|
-
print prompt
|
|
113
|
-
$stdin.gets&.chomp
|
|
114
|
-
end
|
|
99
|
+
def prompt_username
|
|
100
|
+
print "rubygems.org username: "
|
|
101
|
+
value = $stdin.gets&.chomp
|
|
102
|
+
abort "[ERROR] No username provided." if value.nil? || value.empty?
|
|
103
|
+
value
|
|
104
|
+
end
|
|
115
105
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
106
|
+
def read_password(prompt)
|
|
107
|
+
require "io/console"
|
|
108
|
+
print prompt
|
|
109
|
+
password = $stdin.noecho(&:gets)&.chomp
|
|
110
|
+
puts
|
|
111
|
+
password
|
|
112
|
+
rescue LoadError
|
|
113
|
+
print prompt
|
|
114
|
+
$stdin.gets&.chomp
|
|
121
115
|
end
|
|
122
116
|
|
|
123
|
-
|
|
124
|
-
|
|
117
|
+
def store_in_woodpecker(api_key)
|
|
118
|
+
unless woodpecker_server
|
|
119
|
+
puts "[INFO] Set WOODPECKER_SERVER to auto-store the key in Woodpecker CI."
|
|
120
|
+
puts "[INFO] New API key (store as secret '#{woodpecker_secret_name}'): #{api_key}"
|
|
121
|
+
return
|
|
122
|
+
end
|
|
125
123
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
puts "[SUCCESS] API key stored in Woodpecker secret '#{woodpecker_secret_name}'."
|
|
129
|
-
end
|
|
124
|
+
token = read_woodpecker_token
|
|
125
|
+
abort "[ERROR] No Woodpecker token. Set WOODPECKER_TOKEN or run woodpecker-cli setup." unless token
|
|
130
126
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
127
|
+
WoodpeckerSecretStore.new(server: woodpecker_server, org: woodpecker_org, token: token)
|
|
128
|
+
.store(woodpecker_secret_name, api_key)
|
|
129
|
+
puts "[SUCCESS] API key stored in Woodpecker secret '#{woodpecker_secret_name}'."
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def read_woodpecker_token
|
|
133
|
+
ENV.fetch("WOODPECKER_TOKEN", nil) ||
|
|
134
|
+
File.read(File.expand_path("~/.config/woodpecker/token")).strip
|
|
135
|
+
rescue Errno::ENOENT
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
136
138
|
end
|
|
137
139
|
end
|
|
138
140
|
end
|
|
@@ -1,93 +1,95 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Rake
|
|
4
|
-
module
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
4
|
+
module Gem
|
|
5
|
+
module Maintenance
|
|
6
|
+
# Pre-configured gem repository configurations for common setups.
|
|
7
|
+
#
|
|
8
|
+
# @example Use internal-only repos
|
|
9
|
+
# Rake::Gem::Maintenance::UpgradeTask.new do |t|
|
|
10
|
+
# t.gem_repositories = Rake::Gem::Maintenance::Repos.internal
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# @example Use both rubygems.org and internal repos
|
|
14
|
+
# Rake::Gem::Maintenance::UpgradeTask.new do |t|
|
|
15
|
+
# t.gem_repositories = Rake::Gem::Maintenance::Repos.all
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @example Use local geminabox only
|
|
19
|
+
# Rake::Gem::Maintenance::GeminaboxUpgradeTask.new
|
|
20
|
+
#
|
|
21
|
+
# @example Dual publishing: geminabox + rubygems.org
|
|
22
|
+
# Rake::Gem::Maintenance::UpgradeTask.new do |t|
|
|
23
|
+
# t.gem_repositories = Rake::Gem::Maintenance::Repos.geminabox +
|
|
24
|
+
# Rake::Gem::Maintenance::Repos.rubygems
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# @example Reconfigure internal URL
|
|
28
|
+
# Rake::Gem::Maintenance::Repos.internal_url = "https://my-internal-gem.example.com"
|
|
29
|
+
#
|
|
30
|
+
# @example Configure API key and TOTP seed env vars
|
|
31
|
+
# Rake::Gem::Maintenance::Repos.rubygems_api_key_env_var = "GEM_HOST_API_KEY"
|
|
32
|
+
# Rake::Gem::Maintenance::Repos.rubygems_otp_seed_env_var = "RUBYGEMS_OTP_SEED"
|
|
33
|
+
# Rake::Gem::Maintenance::Repos.geminabox_url = "http://localhost:9292"
|
|
34
|
+
module Repos
|
|
35
|
+
@internal_url = "https://gems.cbp-org.internal"
|
|
36
|
+
@rubygems_url = "https://rubygems.org"
|
|
37
|
+
@geminabox_url = "http://localhost:9292"
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
@rubygems_api_key_env_var = nil
|
|
40
|
+
@internal_api_key_env_var = nil
|
|
41
|
+
@geminabox_api_key_env_var = nil
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
@rubygems_otp_seed_env_var = nil
|
|
44
|
+
@internal_otp_seed_env_var = nil
|
|
45
|
+
@geminabox_otp_seed_env_var = nil
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
class << self
|
|
48
|
+
attr_accessor :internal_url, :rubygems_url, :geminabox_url,
|
|
49
|
+
:rubygems_api_key_env_var, :internal_api_key_env_var,
|
|
50
|
+
:geminabox_api_key_env_var,
|
|
51
|
+
:rubygems_otp_seed_env_var, :internal_otp_seed_env_var,
|
|
52
|
+
:geminabox_otp_seed_env_var
|
|
53
|
+
end
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
55
|
+
# Publish only to internal repository
|
|
56
|
+
# @return [Array<Hash>] repository configuration
|
|
57
|
+
def self.internal
|
|
58
|
+
base = { name: "cbp-org", url: internal_url }
|
|
59
|
+
base[:api_key_env_var] = internal_api_key_env_var if internal_api_key_env_var
|
|
60
|
+
base[:otp_seed_env_var] = internal_otp_seed_env_var if internal_otp_seed_env_var
|
|
61
|
+
[base]
|
|
62
|
+
end
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
# Publish to both rubygems.org and internal repository
|
|
65
|
+
# @return [Array<Hash>] repository configuration
|
|
66
|
+
def self.all
|
|
67
|
+
rubygems + internal
|
|
68
|
+
end
|
|
68
69
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
70
|
+
# Publish only to rubygems.org (the default)
|
|
71
|
+
# @return [Array<Hash>] repository configuration
|
|
72
|
+
def self.rubygems
|
|
73
|
+
base = { name: "rubygems", url: rubygems_url }
|
|
74
|
+
base[:api_key_env_var] = rubygems_api_key_env_var if rubygems_api_key_env_var
|
|
75
|
+
base[:otp_seed_env_var] = rubygems_otp_seed_env_var if rubygems_otp_seed_env_var
|
|
76
|
+
[base]
|
|
77
|
+
end
|
|
77
78
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
79
|
+
# Publish only to a local geminabox instance
|
|
80
|
+
# @return [Array<Hash>] repository configuration
|
|
81
|
+
def self.geminabox
|
|
82
|
+
base = { name: "geminabox", url: geminabox_url }
|
|
83
|
+
base[:api_key_env_var] = geminabox_api_key_env_var if geminabox_api_key_env_var
|
|
84
|
+
base[:otp_seed_env_var] = geminabox_otp_seed_env_var if geminabox_otp_seed_env_var
|
|
85
|
+
[base]
|
|
86
|
+
end
|
|
86
87
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
# Default configuration: rubygems.org only
|
|
89
|
+
# @return [Array<Hash>] repository configuration
|
|
90
|
+
def self.default
|
|
91
|
+
rubygems
|
|
92
|
+
end
|
|
91
93
|
end
|
|
92
94
|
end
|
|
93
95
|
end
|
|
@@ -1,49 +1,51 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Rake
|
|
4
|
-
module
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
module Gem
|
|
5
|
+
module Maintenance
|
|
6
|
+
# Creates a new scoped API key on rubygems.org via the v2 API.
|
|
7
|
+
# Handles OTP header injection and maps HTTP error codes to actionable messages.
|
|
8
|
+
class RubyGemsApiKeyCreator
|
|
9
|
+
def initialize(host: "https://rubygems.org")
|
|
10
|
+
@host = host
|
|
11
|
+
end
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
def create(username, password, otp: nil)
|
|
14
|
+
require "net/http"
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
request = build_request(username, password, otp)
|
|
17
|
+
response = http_client.request(request)
|
|
18
|
+
parse_response(response)
|
|
19
|
+
end
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
private
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
23
|
+
def build_request(username, password, otp)
|
|
24
|
+
uri = URI("#{@host}/api/v1/api_key")
|
|
25
|
+
key_name = "rake-gem-maintenance-ci-#{Time.now.strftime('%Y%m%d')}"
|
|
26
|
+
req = Net::HTTP::Post.new(uri)
|
|
27
|
+
req.basic_auth(username, password)
|
|
28
|
+
req["OTP"] = otp if otp
|
|
29
|
+
req["Content-Type"] = "application/x-www-form-urlencoded"
|
|
30
|
+
req.body = "name=#{key_name}&push_rubygem=true"
|
|
31
|
+
req
|
|
32
|
+
end
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
def http_client
|
|
35
|
+
uri = URI(@host)
|
|
36
|
+
http = Net::HTTP.new(uri.hostname, uri.port)
|
|
37
|
+
http.use_ssl = true
|
|
38
|
+
http
|
|
39
|
+
end
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
def parse_response(response)
|
|
42
|
+
case response.code.to_i
|
|
43
|
+
when 200, 201 then response.body.strip
|
|
44
|
+
when 401 then abort "[ERROR] Invalid credentials for #{@host}."
|
|
45
|
+
when 403 then abort "[ERROR] Forbidden. Check your MFA settings on #{@host}."
|
|
46
|
+
when 449 then abort "[ERROR] OTP required by #{@host}. Set RUBYGEMS_OTP_SEED and retry."
|
|
47
|
+
else abort "[ERROR] #{@host} returned #{response.code}: #{response.body.strip}"
|
|
48
|
+
end
|
|
47
49
|
end
|
|
48
50
|
end
|
|
49
51
|
end
|