rake-gem-maintenance 0.1.5 → 0.1.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4047b2b201af066a228e897999e10c6a4e09120f339d927850b73c6139d74cfc
4
- data.tar.gz: 736acd4fa8374821c1fc2cdc457cebd5b3da05c3dd6663a8a216187be6817069
3
+ metadata.gz: 6dc9985cb348ee5f79a3170eb6ef534b5fe8bc1aad6361be15178f883ba22767
4
+ data.tar.gz: e819c2d3f58a936c59fb8caf620f84e92dc68bf59cee8f592bb852c03d02de62
5
5
  SHA512:
6
- metadata.gz: 0a708f6a6ac58894aae62e941a45294dfc6dbb76f71d3d88af6e8d1e254602f5b9848411bf3cc6686271b71357854f00a8644785776f28da668a3e8cf29df7ad
7
- data.tar.gz: ca9cf67f6d8cb9622c218eaf338e7f6e0c9c439c9934f199c1c45de0496e60c747289a11b2b96b35060c40bab167763062c61af2fa5d326b7bdfcff06af46641
6
+ metadata.gz: 43b4d7dec762bcefd6cd816f4c7a8c05a5e091929937f6e329801a6d4ff211fabdac4631f449d39501807b408f0f1411dc97637519857da49911cafa5bd108f6
7
+ data.tar.gz: 81d529473f1404940f0cc45d1011af5d1930506f972b2485cbe190f184cd53a820cbfaa683201ec0b8ef85e63f2d50714cf337780087a94704071a5ef82d735c
@@ -0,0 +1,52 @@
1
+ # publish.yml — build and publish rake-gem-maintenance to rubygems.org
2
+ #
3
+ # Triggers:
4
+ # - push to main (auto-publish on every merge)
5
+ # - manual (ad-hoc publish on demand)
6
+ #
7
+ # Required Woodpecker secrets (org-level cbp-org):
8
+ # rubygems_api_key — rubygems.org API key (set at https://ci.cbp-org.internal)
9
+ # rubygems_otp_seed — base32 TOTP seed for rubygems.org 2FA (already registered)
10
+ # badge_service_token — write token for badge.cbp-org.internal
11
+
12
+ when:
13
+ event: [push, manual]
14
+ branch: main
15
+
16
+ labels:
17
+ platform: linux
18
+
19
+ clone:
20
+ git:
21
+ image: woodpeckerci/plugin-git:2.8.0
22
+ settings:
23
+ skip_verify: true
24
+
25
+ steps:
26
+ - name: publish-gem
27
+ image: ruby:4.0.2-alpine
28
+ backend_options:
29
+ docker:
30
+ network_mode: host
31
+ environment:
32
+ GEM_HOST_API_KEY:
33
+ from_secret: rubygems_api_key
34
+ RUBYGEMS_OTP_SEED:
35
+ from_secret: rubygems_otp_seed
36
+ commands:
37
+ - apk add --no-cache build-base
38
+ - bundle install --jobs 4 --retry 3
39
+ - ruby scripts/ci_publish_rubygems.rb
40
+
41
+ - name: badge
42
+ image: docker-registry.cbp-org.internal/badge-reporter:latest
43
+ when:
44
+ status: [success, failure]
45
+ backend_options:
46
+ docker:
47
+ network_mode: host
48
+ environment:
49
+ BADGE_SECRET:
50
+ from_secret: badge_service_token
51
+ commands:
52
+ - badge report publish
@@ -0,0 +1,47 @@
1
+ when:
2
+ - event: cron
3
+ cron: monthly-renew-api-key
4
+ - event: manual
5
+
6
+ labels:
7
+ platform: linux
8
+
9
+ clone:
10
+ git:
11
+ image: woodpeckerci/plugin-git:2.8.0
12
+ settings:
13
+ skip_verify: true
14
+
15
+ steps:
16
+ - name: renew-api-key
17
+ image: ruby:4.0.2-alpine
18
+ backend_options:
19
+ docker:
20
+ network_mode: host
21
+ environment:
22
+ RUBYGEMS_USERNAME:
23
+ from_secret: rubygems_username
24
+ RUBYGEMS_PASSWORD:
25
+ from_secret: rubygems_password
26
+ RUBYGEMS_OTP_SEED:
27
+ from_secret: rubygems_otp_seed
28
+ WOODPECKER_TOKEN:
29
+ from_secret: woodpecker_api_token
30
+ WOODPECKER_SERVER: "https://ci.cbp-org.internal"
31
+ commands:
32
+ - apk add --no-cache build-base
33
+ - bundle install --jobs 4 --retry 3
34
+ - bundle exec rake upgrade:renew_api_key
35
+
36
+ - name: badge
37
+ image: docker-registry.cbp-org.internal/badge-reporter:latest
38
+ when:
39
+ status: [success, failure]
40
+ backend_options:
41
+ docker:
42
+ network_mode: host
43
+ environment:
44
+ BADGE_SECRET:
45
+ from_secret: badge_service_token
46
+ commands:
47
+ - badge report renew-api-key
@@ -0,0 +1,38 @@
1
+ when:
2
+ event: [push, pull_request, manual]
3
+
4
+ labels:
5
+ platform: linux
6
+
7
+ clone:
8
+ git:
9
+ image: woodpeckerci/plugin-git:2.8.0
10
+ settings:
11
+ skip_verify: true
12
+
13
+ steps:
14
+ - name: verify
15
+ image: ruby:4.0.2-alpine
16
+ backend_options:
17
+ docker:
18
+ network_mode: host
19
+ environment:
20
+ CUCUMBER_PUBLISH_QUIET: "true"
21
+ commands:
22
+ - apk add --no-cache build-base
23
+ - mkdir -p /root/.local/share/ruby-advisory-db/gems
24
+ - bundle install --jobs 4 --retry 3
25
+ - bundle exec rake verify
26
+
27
+ - name: badge
28
+ image: docker-registry.cbp-org.internal/badge-reporter:latest
29
+ when:
30
+ status: [success, failure]
31
+ backend_options:
32
+ docker:
33
+ network_mode: host
34
+ environment:
35
+ BADGE_SECRET:
36
+ from_secret: badge_service_token
37
+ commands:
38
+ - badge report verify
data/CLAUDE.md CHANGED
@@ -54,6 +54,20 @@ Rake::GemMaintenance::UpgradeTask.new do |t|
54
54
  end
55
55
  ```
56
56
 
57
+ ## Repository & CI Workflow
58
+
59
+ **GitHub** (`github.com/cbroult/rake-gem-maintenance`) is the canonical source.
60
+ **Forgejo** (`git.cbp-org.internal/forgejo-admin/rake-gem-maintenance`) is a read-only pull mirror.
61
+
62
+ - All pull requests go on **GitHub only**. Never open PRs on Forgejo.
63
+ - Forgejo mirrors GitHub automatically every 10 minutes — never push to the `forgejo` remote manually.
64
+ - **Woodpecker CI** (`ci.cbp-org.internal`) watches Forgejo and runs verify/publish/renew pipelines.
65
+ - Local clone only needs the `origin` (GitHub) remote. Remove `forgejo` if present: `git remote remove forgejo`.
66
+
67
+ ## Code Style
68
+
69
+ - Never add `# rubocop:disable` comments without explicit user permission.
70
+
57
71
  ## Commit Conventions
58
72
 
59
73
  - Use conventional commit format: `type(scope): subject`
data/Gemfile.lock CHANGED
@@ -1,18 +1,20 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rake-gem-maintenance (0.1.5)
4
+ rake-gem-maintenance (0.1.6)
5
5
  bundler-audit
6
6
  gem-release
7
7
  rake
8
+ rotp
8
9
 
9
10
  GEM
10
11
  remote: https://rubygems.org/
11
12
  specs:
12
- aruba (2.3.3)
13
+ aruba (2.4.0)
13
14
  bundler (>= 1.17)
14
15
  contracts (>= 0.16.0, < 0.18.0)
15
- cucumber (>= 8.0, < 11.0)
16
+ cucumber (>= 8.0, < 12.0)
17
+ irb (~> 1.16)
16
18
  rspec-expectations (>= 3.4, < 5.0)
17
19
  thor (~> 1.0)
18
20
  ast (2.4.3)
@@ -24,18 +26,18 @@ GEM
24
26
  thor (~> 1.0)
25
27
  coderay (1.1.3)
26
28
  contracts (0.17.3)
27
- cucumber (10.2.0)
29
+ cucumber (11.0.0)
28
30
  base64 (~> 0.2)
29
31
  builder (~> 3.2)
30
32
  cucumber-ci-environment (> 9, < 12)
31
- cucumber-core (> 15, < 17)
33
+ cucumber-core (>= 16.2.0, < 17)
32
34
  cucumber-cucumber-expressions (> 17, < 20)
33
- cucumber-html-formatter (> 21, < 23)
35
+ cucumber-html-formatter (> 21, < 24)
34
36
  diff-lcs (~> 1.5)
35
37
  logger (~> 1.6)
36
38
  mini_mime (~> 1.1)
37
39
  multi_test (~> 1.1)
38
- sys-uname (~> 1.3)
40
+ sys-uname (~> 1.5)
39
41
  cucumber-ci-environment (11.0.0)
40
42
  cucumber-core (16.2.0)
41
43
  cucumber-gherkin (> 36, < 40)
@@ -43,16 +45,15 @@ GEM
43
45
  cucumber-tag-expressions (> 6, < 9)
44
46
  cucumber-cucumber-expressions (19.0.0)
45
47
  bigdecimal
46
- cucumber-gherkin (39.0.0)
48
+ cucumber-gherkin (39.1.0)
47
49
  cucumber-messages (>= 31, < 33)
48
- cucumber-html-formatter (22.3.0)
50
+ cucumber-html-formatter (23.1.0)
49
51
  cucumber-messages (> 23, < 33)
50
52
  cucumber-messages (32.3.1)
51
53
  cucumber-tag-expressions (8.1.0)
52
54
  date (3.5.1)
53
55
  diff-lcs (1.6.2)
54
56
  erb (6.0.4)
55
- ffi (1.17.4-x64-mingw-ucrt)
56
57
  ffi (1.17.4-x86_64-linux-gnu)
57
58
  formatador (1.2.3)
58
59
  reline
@@ -83,7 +84,12 @@ GEM
83
84
  guard (~> 2.0)
84
85
  rubocop (< 2.0)
85
86
  io-console (0.8.2)
86
- json (2.19.4)
87
+ irb (1.18.0)
88
+ pp (>= 0.6.0)
89
+ prism (>= 1.3.0)
90
+ rdoc (>= 4.0.0)
91
+ reline (>= 0.4.2)
92
+ json (2.19.5)
87
93
  language_server-protocol (3.17.0.5)
88
94
  lint_roller (1.1.0)
89
95
  listen (3.10.0)
@@ -105,6 +111,9 @@ GEM
105
111
  parser (3.3.11.1)
106
112
  ast (~> 2.4.1)
107
113
  racc
114
+ pp (0.6.3)
115
+ prettyprint
116
+ prettyprint (0.2.0)
108
117
  prism (1.9.0)
109
118
  pry (0.16.0)
110
119
  coderay (~> 1.1)
@@ -126,6 +135,7 @@ GEM
126
135
  regexp_parser (2.12.0)
127
136
  reline (0.6.3)
128
137
  io-console (~> 0.5)
138
+ rotp (6.3.0)
129
139
  rspec (3.13.2)
130
140
  rspec-core (~> 3.13.0)
131
141
  rspec-expectations (~> 3.13.0)
@@ -139,7 +149,7 @@ GEM
139
149
  diff-lcs (>= 1.2.0, < 2.0)
140
150
  rspec-support (~> 3.13.0)
141
151
  rspec-support (3.13.7)
142
- rubocop (1.86.1)
152
+ rubocop (1.86.2)
143
153
  json (~> 2.3)
144
154
  language_server-protocol (~> 3.17.0.2)
145
155
  lint_roller (~> 1.1.0)
@@ -165,19 +175,13 @@ GEM
165
175
  sys-uname (1.5.1)
166
176
  ffi (~> 1.1)
167
177
  memoist3 (~> 1.0.0)
168
- sys-uname (1.5.1-universal-mingw32)
169
- ffi (~> 1.1)
170
- memoist3 (~> 1.0.0)
171
- win32ole
172
178
  thor (1.5.0)
173
179
  tsort (0.2.0)
174
180
  unicode-display_width (3.2.0)
175
181
  unicode-emoji (~> 4.1)
176
182
  unicode-emoji (4.2.0)
177
- win32ole (1.9.3)
178
183
 
179
184
  PLATFORMS
180
- x64-mingw-ucrt
181
185
  x86_64-linux
182
186
 
183
187
  DEPENDENCIES
@@ -197,7 +201,7 @@ DEPENDENCIES
197
201
  rubocop-rspec
198
202
 
199
203
  CHECKSUMS
200
- aruba (2.3.3) sha256=837a2f023368a75a38ad9be227e9738ab9af7df3b3f35afd8fb5fc5f7a93f1d4
204
+ aruba (2.4.0) sha256=92bb696efb06b1aa1dc3ff8b13ca71cd727e59d27572f9264216b9722b558299
201
205
  ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
202
206
  base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
203
207
  bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
@@ -205,18 +209,17 @@ CHECKSUMS
205
209
  bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9
206
210
  coderay (1.1.3) sha256=dc530018a4684512f8f38143cd2a096c9f02a1fc2459edcfe534787a7fc77d4b
207
211
  contracts (0.17.3) sha256=e72e626413ea47099becb7b5683beb1c2ea902c69f5bad55c9258fe2b48314d7
208
- cucumber (10.2.0) sha256=fdedbd31ecf40858b60f04853f2aa15c44f5c30bbac29c6a227fa1e7005a8158
212
+ cucumber (11.0.0) sha256=14bb964cc172999e9010fece2fad9f104044b055b3199230091894637a2a784c
209
213
  cucumber-ci-environment (11.0.0) sha256=0df79a9e1d0b015b3d9def680f989200d96fef206f4d19ccf86a338c4f71d1e2
210
214
  cucumber-core (16.2.0) sha256=592b58a95cf42feef8e5a349f68e363784ba3b6568ffbcf6776e38e136cf970b
211
215
  cucumber-cucumber-expressions (19.0.0) sha256=33208ff204732ac9bed42b46993a0a243054f71ece08579d57e53df6a1c9d93a
212
- cucumber-gherkin (39.0.0) sha256=46f51d87e910f41c3c5cee3b500028ca2b2e7149a413a8280b9a58cee2593e55
213
- cucumber-html-formatter (22.3.0) sha256=f9768ed05588dbd73a5f3824c2cc648bd86b00206e6972d743af8051281d0729
216
+ cucumber-gherkin (39.1.0) sha256=aed12a0c955d8563d80a012633c1a72075525f4d64d4cc983001df2181b379ed
217
+ cucumber-html-formatter (23.1.0) sha256=7789b4a792c876394b9604aeb66aa5cf4c61514473b7e712c76d5eaedcdd8cdf
214
218
  cucumber-messages (32.3.1) sha256=ddc88e4c1cf7afb96c06005b92a4a6f221a2fa435a8b4ca04677d215fd82771c
215
219
  cucumber-tag-expressions (8.1.0) sha256=9bd8c4b6654f8e5bf2a9c99329b6f32136a75e50cd39d4cfb3927d0fa9f52e21
216
220
  date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
217
221
  diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
218
222
  erb (6.0.4) sha256=38e3803694be357fe2bfe312487c74beaf9fb4e5beb3e22498952fe1645b95d9
219
- ffi (1.17.4-x64-mingw-ucrt) sha256=f6ff9618cfccc494138bddade27aa06c74c6c7bc367a1ea1103d80c2fcb9ed35
220
223
  ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
221
224
  formatador (1.2.3) sha256=19fa898133c2c26cdbb5d09f6998c1e137ad9427a046663e55adfe18b950d894
222
225
  gem-release (2.2.4) sha256=2f11124c1580c811507c3b47e875e420cf3ed792a98105b49df11971e6e94db3
@@ -227,7 +230,8 @@ CHECKSUMS
227
230
  guard-rspec (4.7.3) sha256=a47ba03cbd1e3c71e6ae8645cea97e203098a248aede507461a43e906e2f75ca
228
231
  guard-rubocop (1.5.0) sha256=3041d796dcb5ee31e352de74732250826f5f235b4ff48df9dbf424a6dc736251
229
232
  io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
230
- json (2.19.4) sha256=670a7d333fb3b18ca5b29cb255eb7bef099e40d88c02c80bd42a3f30fe5239ac
233
+ irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
234
+ json (2.19.5) sha256=218a18553e4801d579ca7e0f5bc72bafd776d7397238a1fb4e74db5b0a812c59
231
235
  language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
232
236
  lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
233
237
  listen (3.10.0) sha256=c6e182db62143aeccc2e1960033bebe7445309c7272061979bb098d03760c9d2
@@ -242,24 +246,27 @@ CHECKSUMS
242
246
  ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
243
247
  parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356
244
248
  parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54
249
+ pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6
250
+ prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
245
251
  prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
246
252
  pry (0.16.0) sha256=d76c69065698ed1f85e717bd33d7942c38a50868f6b0673c636192b3d1b6054e
247
253
  psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974
248
254
  racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
249
255
  rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
250
256
  rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
251
- rake-gem-maintenance (0.1.5)
257
+ rake-gem-maintenance (0.1.6)
252
258
  rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe
253
259
  rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e
254
260
  rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192
255
261
  regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
256
262
  reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
263
+ rotp (6.3.0) sha256=75d40087e65ed0d8022c33055a6306c1c400d1c12261932533b5d6cbcd868854
257
264
  rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587
258
265
  rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d
259
266
  rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836
260
267
  rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47
261
268
  rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c
262
- rubocop (1.86.1) sha256=44415f3f01d01a21e01132248d2fd0867572475b566ca188a0a42133a08d4531
269
+ rubocop (1.86.2) sha256=bb2e97f635eda42c448f2588f4a6ff78f221b8bdfdf65b1e9b07fbd57521b45d
263
270
  rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035
264
271
  rubocop-rake (0.7.1) sha256=3797f2b6810c3e9df7376c26d5f44f3475eda59eb1adc38e6f62ecf027cbae4d
265
272
  rubocop-rspec (3.9.0) sha256=8fa70a3619408237d789aeecfb9beef40576acc855173e60939d63332fdb55e2
@@ -267,12 +274,10 @@ CHECKSUMS
267
274
  shellany (0.0.1) sha256=0e127a9132698766d7e752e82cdac8250b6adbd09e6c0a7fbbb6f61964fedee7
268
275
  stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
269
276
  sys-uname (1.5.1) sha256=784d7e6491b0393c25cbbe5ac38324ac7be9fda083a6094832648af669386d7b
270
- sys-uname (1.5.1-universal-mingw32) sha256=aceb618e3276da5eae0ce368e9f6fae8c1f3e9ef23a0595cb88db7b6ecd45f62
271
277
  thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
272
278
  tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
273
279
  unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
274
280
  unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
275
- win32ole (1.9.3) sha256=01f43dc5dc13806e6e58204f538b4a28f3d85968ea89074abc9a3cd118e94d96
276
281
 
277
282
  BUNDLED WITH
278
283
  4.0.4
data/README.md CHANGED
@@ -46,6 +46,81 @@ Rake::GemMaintenance::VersionBumpTask.new do |t|
46
46
  end
47
47
  ```
48
48
 
49
+ ## Automated Publishing to rubygems.org
50
+
51
+ Set two environment variables and `gem push` runs fully unattended — including TOTP 2FA code
52
+ generation if your rubygems.org account has MFA enabled.
53
+
54
+ | Env var | Purpose |
55
+ |---|---|
56
+ | `GEM_HOST_API_KEY` | rubygems.org API key (scoped to push) |
57
+ | `RUBYGEMS_OTP_SEED` | Base32 TOTP seed — auto-generates the 2FA code; omit if MFA is disabled |
58
+
59
+ ### Quick setup
60
+
61
+ `require "rake/gem_maintenance/install_tasks"` pre-configures both env var names automatically —
62
+ no extra Ruby needed. See [features/install_tasks.feature](features/install_tasks.feature) for
63
+ the full workflow.
64
+
65
+ ### Custom env var names
66
+
67
+ ```ruby
68
+ require "rake/gem/maintenance"
69
+
70
+ Rake::GemMaintenance::Repos.rubygems_api_key_env_var = "MY_RUBYGEMS_KEY"
71
+ Rake::GemMaintenance::Repos.rubygems_otp_seed_env_var = "MY_OTP_SEED"
72
+
73
+ Rake::GemMaintenance::UpgradeTask.new
74
+ ```
75
+
76
+ See [features/upgrade_task/repos_configuration.feature](features/upgrade_task/repos_configuration.feature)
77
+ for all configuration options including geminabox and dual publishing.
78
+
79
+ ### Local credential store
80
+
81
+ After the first successful `upgrade:renew_api_key` run, the API key and OTP seed are saved to:
82
+
83
+ ```
84
+ ~/.config/rake-gem-maintenance/credentials.yml # Linux / Mac (respects $XDG_CONFIG_HOME)
85
+ %APPDATA%\rake-gem-maintenance\credentials.yml # Windows
86
+ ```
87
+
88
+ The file is created with `0600` permissions (owner-read-only on Unix). It stores `username`,
89
+ `gem_host_api_key`, and `rubygems_otp_seed` — **never the password**. Any project using
90
+ `require "rake/gem_maintenance/install_tasks"` automatically loads the key and OTP seed from
91
+ this file at startup, so `gem push` works without any manual env-var setup.
92
+
93
+ See [features/upgrade_task/credential_store.feature](features/upgrade_task/credential_store.feature)
94
+ for the full behaviour specification.
95
+
96
+ ### API key renewal
97
+
98
+ API keys can be rotated in two ways:
99
+
100
+ **Automatic** — when `gem push` returns a 401/403, the publisher transparently obtains a new
101
+ key using `RUBYGEMS_USERNAME` + `RUBYGEMS_PASSWORD` (+ TOTP from `RUBYGEMS_OTP_SEED` if MFA is
102
+ enabled), then retries the push once. No intervention needed.
103
+
104
+ **On-demand** — run the task explicitly to rotate ahead of expiry:
105
+
106
+ ```bash
107
+ rake upgrade:renew_api_key
108
+ ```
109
+
110
+ Locally this prompts for credentials interactively. In CI, supply all three env vars for
111
+ unattended operation:
112
+
113
+ | Env var | Purpose |
114
+ |---|---|
115
+ | `RUBYGEMS_USERNAME` | rubygems.org account username or email |
116
+ | `RUBYGEMS_PASSWORD` | rubygems.org account password |
117
+ | `RUBYGEMS_OTP_SEED` | Same TOTP seed as above — reused here to authenticate the key-creation request |
118
+
119
+ The new key is written back to the `GEM_HOST_API_KEY` CI secret automatically (requires
120
+ `WOODPECKER_TOKEN` and `WOODPECKER_SERVER` when running under Woodpecker CI).
121
+
122
+ See [features/upgrade_task/renew_api_key.feature](features/upgrade_task/renew_api_key.feature).
123
+
49
124
  ## License
50
125
 
51
126
  The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rake
4
+ module GemMaintenance
5
+ # Renews a rubygems.org API key using credentials from env vars and
6
+ # persists the new key to Woodpecker CI when server details are available.
7
+ class ApiKeyRenewer
8
+ def initialize(otp_provider:,
9
+ username_env_var: "RUBYGEMS_USERNAME",
10
+ password_env_var: "RUBYGEMS_PASSWORD")
11
+ @otp_provider = otp_provider
12
+ @username_env_var = username_env_var
13
+ @password_env_var = password_env_var
14
+ end
15
+
16
+ def renew(repository)
17
+ username = env_credential(@username_env_var)
18
+ password = env_credential(@password_env_var)
19
+ return nil if username.nil? || password.nil?
20
+
21
+ otp = @otp_provider.otp_for(repository[:name], otp_seed_env_var: repository[:otp_seed_env_var])
22
+ new_key = RubyGemsApiKeyCreator.new(host: repository.fetch(:url, "https://rubygems.org"))
23
+ .create(username, password, otp: otp)
24
+ persist_to_woodpecker(new_key)
25
+ new_key
26
+ rescue StandardError
27
+ nil
28
+ end
29
+
30
+ private
31
+
32
+ def env_credential(var)
33
+ value = ENV.fetch(var, nil)
34
+ value&.empty? ? nil : value
35
+ end
36
+
37
+ def persist_to_woodpecker(new_key)
38
+ server = ENV.fetch("WOODPECKER_SERVER", nil)
39
+ token = ENV.fetch("WOODPECKER_TOKEN", nil)
40
+ return unless server && token
41
+
42
+ org = ENV.fetch("WOODPECKER_ORG", "cbp-org")
43
+ WoodpeckerSecretStore.new(server: server, org: org, token: token)
44
+ .store("rubygems_api_key", new_key)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rake
4
+ module GemMaintenance
5
+ # Detects whether the current process is running inside a CI environment.
6
+ module CIEnvironment
7
+ def self.ci?
8
+ ENV["CI"].to_s != ""
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+ require "rubygems"
6
+
7
+ module Rake
8
+ module GemMaintenance
9
+ # Persists rubygems.org credentials (username, API key, OTP seed) to a local config file.
10
+ class CredentialStore
11
+ def self.default_path
12
+ base = if Gem.win_platform?
13
+ ENV.fetch("APPDATA", File.expand_path("~"))
14
+ else
15
+ ENV.fetch("XDG_CONFIG_HOME", File.join(Dir.home, ".config"))
16
+ end
17
+ File.join(base, "rake-gem-maintenance", "credentials.yml")
18
+ end
19
+
20
+ def initialize(path: self.class.default_path)
21
+ @path = path
22
+ end
23
+
24
+ attr_reader :path
25
+
26
+ def read
27
+ return {} unless File.exist?(@path)
28
+
29
+ YAML.safe_load_file(@path, symbolize_names: true) || {}
30
+ rescue StandardError
31
+ {}
32
+ end
33
+
34
+ def write(credentials)
35
+ FileUtils.mkdir_p(File.dirname(@path))
36
+ File.write(@path, credentials.transform_keys(&:to_s).to_yaml)
37
+ File.chmod(0o600, @path) unless Gem.win_platform?
38
+ end
39
+
40
+ def apply_to_env(username_env_var:, api_key_env_var:)
41
+ creds = read
42
+ set_env_if_absent(username_env_var, creds[:username])
43
+ set_env_if_absent("RUBYGEMS_OTP_SEED", creds[:rubygems_otp_seed])
44
+ set_env_if_absent(api_key_env_var, creds[:gem_host_api_key])
45
+ end
46
+
47
+ def update(username:, api_key:, api_key_env_var:)
48
+ otp_seed = ENV.fetch("RUBYGEMS_OTP_SEED", nil)
49
+ updated = read.merge(username: username, gem_host_api_key: api_key)
50
+ updated[:rubygems_otp_seed] = otp_seed if otp_seed && !otp_seed.empty?
51
+ write(updated)
52
+ ENV[api_key_env_var] = api_key
53
+ end
54
+
55
+ private
56
+
57
+ def set_env_if_absent(env_var, value)
58
+ return unless value && !value.empty?
59
+ return if (existing = ENV.fetch(env_var, nil)) && !existing.empty?
60
+
61
+ ENV[env_var] = value
62
+ end
63
+ end
64
+ end
65
+ end
@@ -9,8 +9,12 @@ module Rake
9
9
  class GemPublisher
10
10
  attr_reader :repositories, :warnings, :failed_repositories, :successful_repos
11
11
 
12
- def initialize(repositories = default_repositories)
12
+ def initialize(repositories = default_repositories,
13
+ otp_provider: OtpProvider.new,
14
+ ci_environment: CIEnvironment)
13
15
  @repositories = repositories
16
+ @otp_provider = otp_provider
17
+ @ci_environment = ci_environment
14
18
  @warnings = []
15
19
  @failed_pushes = []
16
20
  @failed_repositories = []
@@ -76,16 +80,23 @@ module Rake
76
80
  end
77
81
 
78
82
  def push(gem_file, repository:)
79
- cmd = "gem push #{gem_file} --host #{repository[:url]}"
80
- result = system(cmd)
81
- if result
82
- @published_files << gem_file
83
- @successful_repos << repository[:name]
84
- end
83
+ result = GemPush.new(gem_file, repository, @otp_provider).attempt
84
+ return record_success(gem_file, repository) if result.success
85
+
86
+ record_push_failure(repository, result.error)
85
87
  rescue StandardError => e
86
88
  @failed_pushes << { repository: repository[:name], error: e.message }
87
89
  end
88
90
 
91
+ def record_success(gem_file, repository)
92
+ @published_files << gem_file
93
+ @successful_repos << repository[:name]
94
+ end
95
+
96
+ def record_push_failure(repository, message)
97
+ @failed_pushes << { repository: repository[:name], error: message.strip }
98
+ end
99
+
89
100
  def version_exists_on_all_repos?(gem_name, version)
90
101
  repositories.all? do |repo|
91
102
  versions_on_repository(gem_name, repo).include?(version.to_s)
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Rake
6
+ module GemMaintenance
7
+ # Pushes a single gem file to one repository, retrying with a renewed API key on auth failure.
8
+ class GemPush
9
+ Result = Struct.new(:success, :error)
10
+
11
+ def initialize(gem_file, repository, otp_provider)
12
+ @gem_file = gem_file
13
+ @repository = repository
14
+ @otp_provider = otp_provider
15
+ end
16
+
17
+ def attempt
18
+ out_err, status = Open3.capture2e(env, command)
19
+ return Result.new(true, nil) if status.success?
20
+ return retry_with_renewed_key if auth_failure?(out_err)
21
+
22
+ Result.new(false, out_err)
23
+ end
24
+
25
+ private
26
+
27
+ def command
28
+ cmd = "gem push #{@gem_file} --host #{@repository[:url]}"
29
+ otp = @otp_provider.otp_for(@repository[:name], otp_seed_env_var: @repository[:otp_seed_env_var])
30
+ cmd += " --otp #{otp}" if otp
31
+ cmd
32
+ end
33
+
34
+ def env
35
+ env_var = @repository[:api_key_env_var]
36
+ return {} unless env_var
37
+
38
+ key = ENV.fetch(env_var, nil)
39
+ return {} if key.nil? || key.empty?
40
+
41
+ { "GEM_HOST_API_KEY" => key }
42
+ end
43
+
44
+ def auth_failure?(output)
45
+ output.match?(/unauthorized|api.key|forbidden/i) ||
46
+ output.include?("401") || output.include?("403")
47
+ end
48
+
49
+ def retry_with_renewed_key
50
+ new_key = ApiKeyRenewer.new(otp_provider: @otp_provider).renew(@repository)
51
+ return Result.new(false, "Auth failed and renewal credentials unavailable.") unless new_key
52
+
53
+ _out_err, status = Open3.capture2e(env.merge("GEM_HOST_API_KEY" => new_key), command)
54
+ if status.success?
55
+ Result.new(true, nil)
56
+ else
57
+ Result.new(false, "Push failed after key renewal.")
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -2,5 +2,13 @@
2
2
 
3
3
  require_relative "../maintenance"
4
4
 
5
+ Rake::GemMaintenance::Repos.rubygems_api_key_env_var = "GEM_HOST_API_KEY"
6
+ Rake::GemMaintenance::Repos.rubygems_otp_seed_env_var = "RUBYGEMS_OTP_SEED"
7
+
5
8
  Rake::GemMaintenance::UpgradeTask.new
6
9
  Rake::GemMaintenance::VersionBumpTask.new
10
+
11
+ Rake::GemMaintenance::CredentialStore.new.apply_to_env(
12
+ username_env_var: "RUBYGEMS_USERNAME",
13
+ api_key_env_var: "GEM_HOST_API_KEY"
14
+ )
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rake
4
+ module GemMaintenance
5
+ # Resolves a 2FA OTP code for gem push, either from the environment or interactively.
6
+ # Resolution order for otp_for:
7
+ # 1. RUBYGEMS_OTP env var set → use raw code (works in CI and locally)
8
+ # 2. otp_seed_env_var provided and env var set → generate TOTP code (works in CI and locally)
9
+ # 3. CI environment → nil (gate only interactive prompt)
10
+ # 4. Interactive prompt
11
+ class OtpProvider
12
+ def initialize(ci_environment: CIEnvironment, input: $stdin)
13
+ @ci_environment = ci_environment
14
+ @input = input
15
+ end
16
+
17
+ def otp_for(repository_name, otp_seed_env_var: nil)
18
+ env_otp = ENV.fetch("RUBYGEMS_OTP", nil)
19
+ return env_otp if env_otp && !env_otp.empty?
20
+
21
+ if otp_seed_env_var
22
+ seed = ENV.fetch(otp_seed_env_var, nil)
23
+ return generate_totp(seed) if seed && !seed.empty?
24
+ end
25
+
26
+ return nil if @ci_environment.ci?
27
+
28
+ prompt_for_otp(repository_name)
29
+ end
30
+
31
+ private
32
+
33
+ def generate_totp(seed)
34
+ require "rotp"
35
+ ::ROTP::TOTP.new(seed).now
36
+ end
37
+
38
+ def prompt_for_otp(repository_name)
39
+ print "Enter OTP for #{repository_name} (blank to skip): "
40
+ value = @input.gets&.chomp
41
+ value&.empty? ? nil : value
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake"
4
+ require "rake/tasklib"
5
+ require_relative "credential_store"
6
+
7
+ module Rake
8
+ module GemMaintenance
9
+ # Generates a new rubygems.org API key via the rubygems.org API and stores
10
+ # it in a Woodpecker CI org-level secret. Intended for local developer use only.
11
+ #
12
+ # Creates: <namespace>:renew_api_key
13
+ #
14
+ # Reads WOODPECKER_SERVER and WOODPECKER_TOKEN (or ~/.config/woodpecker/token)
15
+ # from the environment.
16
+ class RenewApiKeyTask < ::Rake::TaskLib
17
+ attr_accessor :namespace_name, :host, :api_key_env_var, :ci_environment,
18
+ :woodpecker_server, :woodpecker_org, :woodpecker_secret_name,
19
+ :username_env_var, :password_env_var, :credential_store
20
+
21
+ def initialize(namespace_name = :upgrade)
22
+ super()
23
+ apply_defaults(namespace_name)
24
+ define_tasks
25
+ end
26
+
27
+ private
28
+
29
+ def apply_defaults(namespace_name)
30
+ @namespace_name = namespace_name
31
+ @host = "https://rubygems.org"
32
+ @api_key_env_var = "GEM_HOST_API_KEY"
33
+ @ci_environment = CIEnvironment
34
+ @woodpecker_server = ENV.fetch("WOODPECKER_SERVER", nil)
35
+ @woodpecker_org = ENV.fetch("WOODPECKER_ORG", "cbp-org")
36
+ @woodpecker_secret_name = "rubygems_api_key"
37
+ @username_env_var = "RUBYGEMS_USERNAME"
38
+ @password_env_var = "RUBYGEMS_PASSWORD"
39
+ @credential_store = CredentialStore.new
40
+ end
41
+
42
+ def define_tasks
43
+ task_instance = self
44
+ namespace namespace_name do
45
+ desc "Generate a new rubygems.org API key and store it in Woodpecker CI"
46
+ task(:renew_api_key) { task_instance.send(:run_renewal) }
47
+ end
48
+ end
49
+
50
+ def run_renewal
51
+ credential_store.apply_to_env(username_env_var: username_env_var, api_key_env_var: api_key_env_var)
52
+ abort_if_ci
53
+ username, password = prompt_credentials
54
+ prompt_otp_seed_if_missing
55
+ api_key = generate_api_key(username, password)
56
+ save_and_distribute(username, api_key)
57
+ end
58
+
59
+ def generate_api_key(username, password)
60
+ otp = OtpProvider.new.otp_for("rubygems")
61
+ RubyGemsApiKeyCreator.new(host: host).create(username, password, otp: otp)
62
+ end
63
+
64
+ def save_and_distribute(username, api_key)
65
+ puts "\n[INFO] New API key generated."
66
+ credential_store.update(username: username, api_key: api_key, api_key_env_var: api_key_env_var)
67
+ store_in_woodpecker(api_key)
68
+ end
69
+
70
+ def abort_if_ci
71
+ return unless ci_environment.ci?
72
+
73
+ missing = [username_env_var, password_env_var].select { |v| env_credential(v).nil? }
74
+ return if missing.empty?
75
+
76
+ abort "[ERROR] Set #{missing.join(' and ')} CI secrets to run renewal unattended."
77
+ end
78
+
79
+ def prompt_otp_seed_if_missing
80
+ return if (seed = ENV.fetch("RUBYGEMS_OTP_SEED", nil)) && !seed.empty?
81
+
82
+ print "rubygems.org OTP seed (TOTP secret, not a code): "
83
+ seed = $stdin.gets&.chomp
84
+ ENV["RUBYGEMS_OTP_SEED"] = seed if seed && !seed.empty?
85
+ end
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
92
+
93
+ def env_credential(env_var)
94
+ value = ENV.fetch(env_var, nil)
95
+ value&.empty? ? nil : value
96
+ end
97
+
98
+ def prompt_username
99
+ print "rubygems.org username: "
100
+ value = $stdin.gets&.chomp
101
+ abort "[ERROR] No username provided." if value.nil? || value.empty?
102
+ value
103
+ end
104
+
105
+ def read_password(prompt)
106
+ require "io/console"
107
+ print prompt
108
+ password = $stdin.noecho(&:gets)&.chomp
109
+ puts
110
+ password
111
+ rescue LoadError
112
+ print prompt
113
+ $stdin.gets&.chomp
114
+ end
115
+
116
+ def store_in_woodpecker(api_key)
117
+ unless woodpecker_server
118
+ puts "[INFO] Set WOODPECKER_SERVER to auto-store the key in Woodpecker CI."
119
+ puts "[INFO] New API key (store as secret '#{woodpecker_secret_name}'): #{api_key}"
120
+ return
121
+ end
122
+
123
+ token = read_woodpecker_token
124
+ abort "[ERROR] No Woodpecker token. Set WOODPECKER_TOKEN or run woodpecker-cli setup." unless token
125
+
126
+ WoodpeckerSecretStore.new(server: woodpecker_server, org: woodpecker_org, token: token)
127
+ .store(woodpecker_secret_name, api_key)
128
+ puts "[SUCCESS] API key stored in Woodpecker secret '#{woodpecker_secret_name}'."
129
+ end
130
+
131
+ def read_woodpecker_token
132
+ ENV.fetch("WOODPECKER_TOKEN", nil) ||
133
+ File.read(File.expand_path("~/.config/woodpecker/token")).strip
134
+ rescue Errno::ENOENT
135
+ nil
136
+ end
137
+ end
138
+ end
139
+ end
@@ -14,35 +14,74 @@ module Rake
14
14
  # t.gem_repositories = Rake::GemMaintenance::Repos.all
15
15
  # end
16
16
  #
17
+ # @example Use local geminabox only
18
+ # Rake::GemMaintenance::GeminaboxUpgradeTask.new
19
+ #
20
+ # @example Dual publishing: geminabox + rubygems.org
21
+ # Rake::GemMaintenance::UpgradeTask.new do |t|
22
+ # t.gem_repositories = Rake::GemMaintenance::Repos.geminabox +
23
+ # Rake::GemMaintenance::Repos.rubygems
24
+ # end
25
+ #
17
26
  # @example Reconfigure internal URL
18
27
  # Rake::GemMaintenance::Repos.internal_url = "https://my-internal-gem.example.com"
28
+ #
29
+ # @example Configure API key and TOTP seed env vars
30
+ # Rake::GemMaintenance::Repos.rubygems_api_key_env_var = "GEM_HOST_API_KEY"
31
+ # Rake::GemMaintenance::Repos.rubygems_otp_seed_env_var = "RUBYGEMS_OTP_SEED"
32
+ # Rake::GemMaintenance::Repos.geminabox_url = "http://localhost:9292"
19
33
  module Repos
20
34
  @internal_url = "https://gems.cbp-org.internal"
21
35
  @rubygems_url = "https://rubygems.org"
36
+ @geminabox_url = "http://localhost:9292"
37
+
38
+ @rubygems_api_key_env_var = nil
39
+ @internal_api_key_env_var = nil
40
+ @geminabox_api_key_env_var = nil
41
+
42
+ @rubygems_otp_seed_env_var = nil
43
+ @internal_otp_seed_env_var = nil
44
+ @geminabox_otp_seed_env_var = nil
22
45
 
23
46
  class << self
24
- attr_accessor :internal_url, :rubygems_url
47
+ attr_accessor :internal_url, :rubygems_url, :geminabox_url,
48
+ :rubygems_api_key_env_var, :internal_api_key_env_var,
49
+ :geminabox_api_key_env_var,
50
+ :rubygems_otp_seed_env_var, :internal_otp_seed_env_var,
51
+ :geminabox_otp_seed_env_var
25
52
  end
26
53
 
27
54
  # Publish only to internal repository
28
55
  # @return [Array<Hash>] repository configuration
29
56
  def self.internal
30
- [{ name: "cbp-org", url: internal_url }]
57
+ base = { name: "cbp-org", url: internal_url }
58
+ base[:api_key_env_var] = internal_api_key_env_var if internal_api_key_env_var
59
+ base[:otp_seed_env_var] = internal_otp_seed_env_var if internal_otp_seed_env_var
60
+ [base]
31
61
  end
32
62
 
33
63
  # Publish to both rubygems.org and internal repository
34
64
  # @return [Array<Hash>] repository configuration
35
65
  def self.all
36
- [
37
- { name: "rubygems", url: rubygems_url },
38
- { name: "cbp-org", url: internal_url }
39
- ]
66
+ rubygems + internal
40
67
  end
41
68
 
42
69
  # Publish only to rubygems.org (the default)
43
70
  # @return [Array<Hash>] repository configuration
44
71
  def self.rubygems
45
- [{ name: "rubygems", url: rubygems_url }]
72
+ base = { name: "rubygems", url: rubygems_url }
73
+ base[:api_key_env_var] = rubygems_api_key_env_var if rubygems_api_key_env_var
74
+ base[:otp_seed_env_var] = rubygems_otp_seed_env_var if rubygems_otp_seed_env_var
75
+ [base]
76
+ end
77
+
78
+ # Publish only to a local geminabox instance
79
+ # @return [Array<Hash>] repository configuration
80
+ def self.geminabox
81
+ base = { name: "geminabox", url: geminabox_url }
82
+ base[:api_key_env_var] = geminabox_api_key_env_var if geminabox_api_key_env_var
83
+ base[:otp_seed_env_var] = geminabox_otp_seed_env_var if geminabox_otp_seed_env_var
84
+ [base]
46
85
  end
47
86
 
48
87
  # Default configuration: rubygems.org only
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rake
4
+ module GemMaintenance
5
+ # Creates a new scoped API key on rubygems.org via the v2 API.
6
+ # Handles OTP header injection and maps HTTP error codes to actionable messages.
7
+ class RubyGemsApiKeyCreator
8
+ def initialize(host: "https://rubygems.org")
9
+ @host = host
10
+ end
11
+
12
+ def create(username, password, otp: nil)
13
+ require "net/http"
14
+
15
+ request = build_request(username, password, otp)
16
+ response = http_client.request(request)
17
+ parse_response(response)
18
+ end
19
+
20
+ private
21
+
22
+ def build_request(username, password, otp)
23
+ uri = URI("#{@host}/api/v1/api_key")
24
+ key_name = "rake-gem-maintenance-ci-#{Time.now.strftime('%Y%m%d')}"
25
+ req = Net::HTTP::Post.new(uri)
26
+ req.basic_auth(username, password)
27
+ req["OTP"] = otp if otp
28
+ req["Content-Type"] = "application/x-www-form-urlencoded"
29
+ req.body = "name=#{key_name}&push_rubygem=true"
30
+ req
31
+ end
32
+
33
+ def http_client
34
+ uri = URI(@host)
35
+ http = Net::HTTP.new(uri.hostname, uri.port)
36
+ http.use_ssl = true
37
+ http
38
+ end
39
+
40
+ def parse_response(response)
41
+ case response.code.to_i
42
+ when 200, 201 then response.body.strip
43
+ when 401 then abort "[ERROR] Invalid credentials for #{@host}."
44
+ when 403 then abort "[ERROR] Forbidden. Check your MFA settings on #{@host}."
45
+ when 449 then abort "[ERROR] OTP required by #{@host}. Set RUBYGEMS_OTP_SEED and retry."
46
+ else abort "[ERROR] #{@host} returned #{response.code}: #{response.body.strip}"
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -3,6 +3,9 @@
3
3
  require "net/http"
4
4
  require "rake"
5
5
  require "rake/tasklib"
6
+ require_relative "ci_environment"
7
+ require_relative "otp_provider"
8
+ require_relative "renew_api_key_task"
6
9
  require_relative "gem_publisher"
7
10
  require_relative "repos"
8
11
 
@@ -20,6 +23,12 @@ module Rake
20
23
  :run_bundle_audit, :auto_pipeline, :gem_repositories,
21
24
  :gem_publisher_class, :gem_name, :gem_version
22
25
 
26
+ attr_writer :renew_api_key_task_class
27
+
28
+ def renew_api_key_task_class
29
+ @renew_api_key_task_class || RenewApiKeyTask
30
+ end
31
+
23
32
  def initialize(name = :upgrade)
24
33
  super()
25
34
  apply_default_configuration(name)
@@ -85,6 +94,7 @@ module Rake
85
94
  define_gems_task
86
95
  define_commit_task
87
96
  define_push_task
97
+ define_renew_api_key_task
88
98
  end
89
99
 
90
100
  def define_info_tasks
@@ -186,6 +196,10 @@ module Rake
186
196
  end
187
197
  end
188
198
 
199
+ def define_renew_api_key_task
200
+ renew_api_key_task_class.new(name)
201
+ end
202
+
189
203
  def pipeline_tasks
190
204
  return auto_pipeline if auto_pipeline
191
205
 
@@ -294,5 +308,14 @@ module Rake
294
308
  @gem_repositories = Repos.all
295
309
  end
296
310
  end
311
+
312
+ # Upgrades gems and publishes to a local geminabox instance only.
313
+ # Uses Repos.geminabox as default repositories.
314
+ class GeminaboxUpgradeTask < UpgradeTask
315
+ def apply_default_configuration(name)
316
+ super
317
+ @gem_repositories = Repos.geminabox
318
+ end
319
+ end
297
320
  end
298
321
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rake
4
4
  module GemMaintenance
5
- VERSION = "0.1.5"
5
+ VERSION = "0.1.6"
6
6
  end
7
7
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rake
4
+ module GemMaintenance
5
+ # Creates or updates an org-level secret in a Woodpecker CI server.
6
+ # SSL verification is disabled because Woodpecker is typically served
7
+ # on an internal network with a private CA.
8
+ class WoodpeckerSecretStore
9
+ def initialize(server:, org:, token:)
10
+ @server = server
11
+ @org = org
12
+ @token = token
13
+ end
14
+
15
+ def store(secret_name, value, events: %w[push tag manual])
16
+ org_id = find_org_id
17
+ abort "[ERROR] Woodpecker org '#{@org}' not found on #{@server}." unless org_id
18
+
19
+ if secret_exists?(org_id, secret_name)
20
+ patch("/api/orgs/#{org_id}/secrets/#{secret_name}",
21
+ { value: value, events: events, images: [] })
22
+ else
23
+ post("/api/orgs/#{org_id}/secrets",
24
+ { name: secret_name, value: value, events: events, images: [] })
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def find_org_id
31
+ orgs = get("/api/orgs")
32
+ orgs&.find { |o| o["name"] == @org }&.fetch("id", nil)
33
+ end
34
+
35
+ def secret_exists?(org_id, secret_name)
36
+ secrets = get("/api/orgs/#{org_id}/secrets")
37
+ secrets&.any? { |s| s["name"] == secret_name }
38
+ end
39
+
40
+ def get(path)
41
+ require "json"
42
+ response = request(Net::HTTP::Get, path)
43
+ JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
44
+ end
45
+
46
+ def post(path, body)
47
+ request(Net::HTTP::Post, path, body)
48
+ end
49
+
50
+ def patch(path, body)
51
+ request(Net::HTTP::Patch, path, body)
52
+ end
53
+
54
+ def request(klass, path, body = nil)
55
+ require "net/http"
56
+ require "json"
57
+ require "openssl"
58
+
59
+ uri = URI("#{@server}#{path}")
60
+ http.request(build_req(klass, uri, body))
61
+ end
62
+
63
+ def http
64
+ uri = URI(@server)
65
+ h = Net::HTTP.new(uri.hostname, uri.port)
66
+ h.use_ssl = (uri.scheme == "https")
67
+ h.verify_mode = OpenSSL::SSL::VERIFY_NONE
68
+ h
69
+ end
70
+
71
+ def build_req(klass, uri, body)
72
+ req = klass.new(uri)
73
+ req["Authorization"] = "Bearer #{@token}"
74
+ if body
75
+ req["Content-Type"] = "application/json"
76
+ req.body = JSON.generate(body)
77
+ end
78
+ req
79
+ end
80
+
81
+ def build_http(uri)
82
+ http = Net::HTTP.new(uri.hostname, uri.port)
83
+ http.use_ssl = (uri.scheme == "https")
84
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
85
+ http
86
+ end
87
+ end
88
+ end
89
+ end
@@ -2,6 +2,14 @@
2
2
 
3
3
  require_relative "maintenance/version"
4
4
  require_relative "maintenance/version_bump_task"
5
+ require_relative "maintenance/ci_environment"
6
+ require_relative "maintenance/credential_store"
7
+ require_relative "maintenance/otp_provider"
8
+ require_relative "maintenance/ruby_gems_api_key_creator"
9
+ require_relative "maintenance/woodpecker_secret_store"
10
+ require_relative "maintenance/api_key_renewer"
11
+ require_relative "maintenance/renew_api_key_task"
12
+ require_relative "maintenance/gem_push"
5
13
  require_relative "maintenance/gem_publisher"
6
14
  require_relative "maintenance/repos"
7
15
  require_relative "maintenance/upgrade_task"
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ci-publish-rubygems.rb — build and push this gem to rubygems.org
4
+ #
5
+ # Required env:
6
+ # GEM_HOST_API_KEY rubygems.org API key (from Woodpecker secret rubygems_api_key)
7
+ # RUBYGEMS_OTP_SEED base32 TOTP seed (from Woodpecker secret rubygems_otp_seed)
8
+
9
+ $LOAD_PATH.unshift File.join(__dir__, "..", "lib")
10
+ require "rake/gem/maintenance"
11
+
12
+ Rake::GemMaintenance::Repos.rubygems_api_key_env_var = "GEM_HOST_API_KEY"
13
+ Rake::GemMaintenance::Repos.rubygems_otp_seed_env_var = "RUBYGEMS_OTP_SEED"
14
+
15
+ gemspec_file = Dir["*.gemspec"].first
16
+ abort "ERROR: No gemspec found in #{Dir.pwd}" unless gemspec_file
17
+
18
+ system("gem build #{gemspec_file}") or abort "ERROR: gem build failed"
19
+
20
+ gem_file = Dir["*.gem"].max_by { |f| File.mtime(f) }
21
+ abort "ERROR: No .gem file found after build" unless gem_file
22
+
23
+ puts "Publishing #{gem_file} to rubygems.org..."
24
+ publisher = Rake::GemMaintenance::GemPublisher.new(Rake::GemMaintenance::Repos.rubygems)
25
+ publisher.publish(gem_file)
26
+
27
+ abort "ERROR: Failed to publish #{gem_file} to rubygems.org" if publisher.successful_repos.empty?
28
+
29
+ puts "Published #{gem_file} successfully to: #{publisher.successful_repos.join(', ')}"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rake-gem-maintenance
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Christophe Broult
@@ -51,6 +51,20 @@ dependencies:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
53
  version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rotp
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
54
68
  description: Provides reusable Rake::TaskLib subclasses for upgrading gem dependencies
55
69
  and bumping versions.
56
70
  email:
@@ -61,6 +75,9 @@ extra_rdoc_files: []
61
75
  files:
62
76
  - ".rspec"
63
77
  - ".rubocop.yml"
78
+ - ".woodpecker/publish.yml"
79
+ - ".woodpecker/renew_api_key.yml"
80
+ - ".woodpecker/verify.yml"
64
81
  - CLAUDE.md
65
82
  - Gemfile
66
83
  - Gemfile.lock
@@ -71,13 +88,21 @@ files:
71
88
  - TODO.md
72
89
  - cucumber.yml
73
90
  - lib/rake/gem/maintenance.rb
91
+ - lib/rake/gem/maintenance/api_key_renewer.rb
92
+ - lib/rake/gem/maintenance/ci_environment.rb
93
+ - lib/rake/gem/maintenance/credential_store.rb
74
94
  - lib/rake/gem/maintenance/gem_publisher.rb
95
+ - lib/rake/gem/maintenance/gem_push.rb
75
96
  - lib/rake/gem/maintenance/install_tasks.rb
97
+ - lib/rake/gem/maintenance/otp_provider.rb
98
+ - lib/rake/gem/maintenance/renew_api_key_task.rb
76
99
  - lib/rake/gem/maintenance/repos.rb
100
+ - lib/rake/gem/maintenance/ruby_gems_api_key_creator.rb
77
101
  - lib/rake/gem/maintenance/upgrade_task.rb
78
102
  - lib/rake/gem/maintenance/version.rb
79
103
  - lib/rake/gem/maintenance/version_bump_task.rb
80
- - rake-gem-maintenance.gemspec
104
+ - lib/rake/gem/maintenance/woodpecker_secret_store.rb
105
+ - scripts/ci_publish_rubygems.rb
81
106
  homepage: https://github.com/cbroult/rake-gem-maintenance
82
107
  licenses:
83
108
  - MIT
@@ -1,32 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/rake/gem/maintenance/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "rake-gem-maintenance"
7
- spec.version = Rake::GemMaintenance::VERSION
8
- spec.authors = ["Christophe Broult"]
9
- spec.email = ["cbroult@yahoo.com"]
10
-
11
- spec.summary = "Rake tasks for gem maintenance: dependency upgrades and version bumps."
12
- spec.description = "Provides reusable Rake::TaskLib subclasses for upgrading gem dependencies and bumping versions."
13
- spec.homepage = "https://github.com/cbroult/rake-gem-maintenance"
14
- spec.license = "MIT"
15
- spec.required_ruby_version = ">= 3.3.7"
16
-
17
- spec.metadata["allowed_push_host"] = "https://rubygems.org"
18
- spec.metadata["homepage_uri"] = spec.homepage
19
- spec.metadata["changelog_uri"] = File.join(spec.homepage, "Changelog")
20
- spec.metadata["rubygems_mfa_required"] = "true"
21
-
22
- spec.files = Dir.chdir(File.expand_path(__dir__)) do
23
- `git ls-files -z`.split("\x0").reject do |f|
24
- (f == __FILE__) || f.match(%r{\A(?:(?:test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
25
- end
26
- end
27
- spec.require_paths = ["lib"]
28
-
29
- spec.add_dependency "bundler-audit"
30
- spec.add_dependency "gem-release"
31
- spec.add_dependency "rake"
32
- end