kettle-dev 3.1.11 → 3.1.13

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: c6532c0b875c1c1581f43bf3035bc3f9b4ff7ba8d04f7a90f6bf78be7fb167ac
4
- data.tar.gz: 9894c1dd7477202261502e23f1e6d37b6f0f174350ac456b834dc8df19b143ba
3
+ metadata.gz: fee58bbb5088875b1370305934e2816bc7b1f7ad5103776d7877f7bfb8383bd2
4
+ data.tar.gz: 75dd9745eda2bd54c6dcf1a9a5ae9bf8b36fdfcb2edd2e8a3c26ab3bde366aa6
5
5
  SHA512:
6
- metadata.gz: 5bf4bd022743bdac03fc5806ff9598781abf17814c14ec9cddd5a917d33af628055dea5adbdbf11ee3d4284aa9f5dc2926c8456dab0710fc59ea7093aa23d457
7
- data.tar.gz: a9aa5312d72d80c8868becdb10cbf8e11f57ae4df5e37edc78f781656873b4dea9bc1fd48aca064a4ec63e76ee2156dfe05626e8efa90743a7ffda4b24a397bd
6
+ metadata.gz: e21b6253a77ecf67f42b54a464405baa078615cc99585f1146f432135f347f5fc0e7c4044137841f3da5f589486fc3971cd8b5dbe6285bf1f194de9b7bbd8359
7
+ data.tar.gz: 0dd4e28b43e6d4ba4ae3481d0464fbeb8b1c7aa39ae1628f0fc9c2ce80fe9d850a738d62414ceda91f6daf43887ee4b400c6621df7b5b1c473bfaa934ade7941
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,51 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [3.1.13] - 2026-10-14
34
+
35
+ - TAG: [v3.1.13][3.1.13t]
36
+ - COVERAGE: 91.53% -- 6427/7022 lines in 49 files
37
+ - BRANCH COVERAGE: 75.69% -- 2456/3245 branches in 49 files
38
+ - 53.23% documented
39
+
40
+ ### Changed
41
+
42
+ - GitHub API request budget is now enforced machine-wide through an flock'd XDG ledger (XDG_STATE_HOME/kettle-dev/ci-rate-budget.json, override with K_RELEASE_CI_RATE_STATE_FILE) instead of per-process memory: parallel family releases, wave jobs, ci:act, and kettle-release GitHub Releases calls all share one budget (default 90 req/min) matching GitHub's per-token quota; unavailable ledger degrades to in-process spacing
43
+
44
+ - [kc] kettle-jem/template: updated 80 project files:
45
+ - code and tests (5)
46
+ - configuration (2)
47
+ - dependencies (15)
48
+ - documentation (1)
49
+ - other (3)
50
+ - workflows (54)
51
+ - [kc] kettle-jem/prepare: updated 1 project file:
52
+ - dependencies (1)
53
+
54
+ ### Fixed
55
+
56
+ - release:guard_clean no longer aborts a --start-step 15 resume: the publish-boundary lockfile normalization added in 3.1.13 lived only inside the step 14 block, so resuming at step 15 skipped it and the parent's own boot (family local-path toggle + gemspec self-version bump) re-dirtied the canonical Gemfile.lock with sibling PATH remotes; normalization now runs once per run from BOTH the step 14 and step 15 entry points
57
+ - LockfileReset#rebuild_lockfile no longer crashes with Errno::ENOENT when a generated appraisal lockfile (e.g. Appraisal.root.gemfile.lock) vanishes between the entry guard and the backup copy (TOCTOU race with mid-process CI cleanup); the rebuild now yields without a backup and skips restore when the target was already gone
58
+
59
+ - Test suite no longer mutates the live repo checkout: appraisal rake task specs now run in a sandboxed CWD (the task's File.delete of the relative Appraisal.root.gemfile.lock path was really deleting the repo's lockfile mid-suite, racing parallel workers), and release_cli flow examples stub reset_release_lockfiles! instead of running real bundler resets against the live checkout — root cause of the ENOENT flake seen in ruby-3.4 CI
60
+
61
+ - CI rate limiter no longer sleeps while holding the machine-wide ledger flock: claims now reserve a future slot under the lock and sleep outside it (ticket limiter), so a wedged or slow claimer can no longer deadlock every other process sharing the ledger — the observed CI hang signature (spec-run worker held the flock for minutes while peers starved past the watchdog). The ledger path override is now read at call time, and the test suite isolates itself onto a per-run tmp ledger instead of the production file
62
+
63
+ ## [3.1.12] - 2026-10-12
64
+
65
+ - TAG: [v3.1.12][3.1.12t]
66
+ - COVERAGE: 91.38% -- 6378/6980 lines in 49 files
67
+ - BRANCH COVERAGE: 75.57% -- 2437/3225 branches in 49 files
68
+ - 52.49% documented
69
+
70
+ ### Fixed
71
+
72
+ - CI monitor no longer stalls silently at 0/N when GitHub API requests fail: request budget throttling (K_RELEASE_CI_REQUESTS_PER_MINUTE, default 90/min) keeps polling inside the quota; exhausted-quota 403s raise CIRateLimitError so the monitor parks until the reset (emitting a github_rate_limited event) instead of treating the wall as 'no data yet'; 401s raise CIAuthError and abort with token guidance; job-level failure polling is gated to K_RELEASE_CI_JOBS_POLL_INTERVAL (default 60s) — the jobs polling added for early failure detection had multiplied request volume past the quota on large matrices
73
+
74
+ - kettle-release no longer aborts at release:guard_clean when resuming at step 14/15: the process boot can silently rewrite the canonical Gemfile.lock with sibling PATH remotes (gemspec self-version bump forces re-resolution under the family local-path toggle), so canonical lockfiles are now normalized and re-validated immediately before the build and rake release children
75
+
76
+ - kettle-release lockfile normalization no longer crashes with Errno::ENOENT when a generated appraisal lockfile (Appraisal.root.gemfile.lock) vanishes between the lockfile scan and the reset in CI
77
+
33
78
  ## [3.1.11] - 2026-10-11
34
79
 
35
80
  - TAG: [v3.1.11][3.1.11t]
@@ -4049,7 +4094,11 @@ Please file a bug if you notice a violation of semantic versioning.
4049
4094
  - Selecting will run the selected workflow via `act`
4050
4095
  - This may move to its own gem in the future.
4051
4096
 
4052
- [Unreleased]: https://github.com/kettle-dev/kettle-dev/compare/v3.1.11...HEAD
4097
+ [Unreleased]: https://github.com/kettle-dev/kettle-dev/compare/v3.1.13...HEAD
4098
+ [3.1.13]: https://github.com/kettle-dev/kettle-dev/compare/v3.1.12...v3.1.13
4099
+ [3.1.13t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v3.1.13
4100
+ [3.1.12]: https://github.com/kettle-dev/kettle-dev/compare/v3.1.11...v3.1.12
4101
+ [3.1.12t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v3.1.12
4053
4102
  [3.1.11]: https://github.com/kettle-dev/kettle-dev/compare/v3.1.10...v3.1.11
4054
4103
  [3.1.11t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v3.1.11
4055
4104
  [3.1.10]: https://github.com/kettle-dev/kettle-dev/compare/v3.1.9...v3.1.10
data/README.md CHANGED
@@ -93,7 +93,7 @@ out to `kettle-release` for each member that needs publishing.
93
93
  | Tokens to Remember | [![Gem name][⛳️name-img]][⛳️gem-name] [![Gem namespace][⛳️namespace-img]][⛳️gem-namespace] |
94
94
  |-------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
95
95
  | Works with JRuby | [![JRuby 9.2 Compat][💎jruby-9.2i]][🚎jruby-9.2-wf] [![JRuby 9.3 Compat][💎jruby-9.3i]][🚎jruby-9.3-wf] <br/> [![JRuby 9.4 Compat][💎jruby-9.4i]][🚎jruby-9.4-wf] [![JRuby 10.0 Compat][💎jruby-10.0i]][🚎jruby-10.0-wf] [![JRuby current Compat][💎jruby-c-i]][🚎10-j-wf] [![JRuby HEAD Compat][💎jruby-headi]][🚎3-hd-wf]|
96
- | Works with Truffle Ruby | [![Truffle Ruby 22.3 Compat][💎truby-22.3i]][🚎truby-22.3-wf] [![Truffle Ruby 23.0 Compat][💎truby-23.0i]][🚎truby-23.0-wf] [![Truffle Ruby 23.1 Compat][💎truby-23.1i]][🚎truby-23.1-wf] <br/> [![Truffle Ruby 24.2 Compat][💎truby-24.2i]][🚎truby-24.2-wf] [![Truffle Ruby 25.0 Compat][💎truby-25.0i]][🚎truby-25.0-wf] [![Truffle Ruby 33.0 Compat][💎truby-33.0i]][🚎truby-33.0-wf] [![Truffle Ruby current Compat][💎truby-c-i]][🚎9-t-wf] [![Truffle Ruby HEAD Compat][💎truby-headi]][🚎3-hd-wf]|
96
+ | Works with Truffle Ruby | [![Truffle Ruby 22.3 Compat][💎truby-22.3i]][🚎truby-22.3-wf] [![Truffle Ruby 23.1 Compat][💎truby-23.1i]][🚎truby-23.1-wf] <br/> [![Truffle Ruby 24.2 Compat][💎truby-24.2i]][🚎truby-24.2-wf] [![Truffle Ruby 25.0 Compat][💎truby-25.0i]][🚎truby-25.0-wf] [![Truffle Ruby 33.0 Compat][💎truby-33.0i]][🚎truby-33.0-wf] [![Truffle Ruby current Compat][💎truby-c-i]][🚎9-t-wf]|
97
97
  | Works with MRI Ruby 4 | [![Ruby current Compat][💎ruby-c-i]][🚎11-c-wf] [![Ruby HEAD Compat][💎ruby-headi]][🚎3-hd-wf]|
98
98
  | Works with MRI Ruby 3 | [![Ruby 3.0 Compat][💎ruby-3.0i]][🚎ruby-3.0-wf] [![Ruby 3.1 Compat][💎ruby-3.1i]][🚎ruby-3.1-wf] [![Ruby 3.2 Compat][💎ruby-3.2i]][🚎ruby-3.2-wf] [![Ruby 3.3 Compat][💎ruby-3.3i]][🚎ruby-3.3-wf] [![Ruby 3.4 Compat][💎ruby-3.4i]][🚎ruby-3.4-wf]|
99
99
  | Works with MRI Ruby 2 | [![Ruby 2.4 Compat][💎ruby-2.4i]][🚎ruby-2.4-wf] [![Ruby 2.5 Compat][💎ruby-2.5i]][🚎ruby-2.5-wf] [![Ruby 2.6 Compat][💎ruby-2.6i]][🚎ruby-2.6-wf] [![Ruby 2.7 Compat][💎ruby-2.7i]][🚎ruby-2.7-wf]|
@@ -132,6 +132,23 @@ The _amazing_ test matrix is powered by the kettle-dev stack.
132
132
 
133
133
  </details>
134
134
 
135
+ ### Runtime and Platform Support
136
+
137
+ Support tiers describe what this project's CI actually exercises, not what may
138
+ work in an untested environment:
139
+
140
+ - **Parallel support**: CI runs the test suite with multiple workers available.
141
+ - **Unsupported**: the engine/platform combination is not covered by CI and is
142
+ not a project compatibility claim.
143
+
144
+ | Ruby engine/version | Linux | macOS | Windows | Notes |
145
+ |---------------------|-------|-------|---------|-------|
146
+ | MRI 3.1-3.4 | Parallel support | Unsupported | Unsupported | Versioned Ruby workflows run on Linux. |
147
+ | MRI current | Parallel support | Parallel support | Unsupported | Current Ruby workflow covers the declared OS platforms. |
148
+ | MRI head | Parallel support | Unsupported | Unsupported | CI is allowed to fail while Ruby is in development. |
149
+ | JRuby (all CI lanes) | Parallel support | Unsupported | Unsupported | Engine workflows run on Linux; macOS and Windows are not exercised by CI. |
150
+ | TruffleRuby (all CI lanes) | Parallel support | Unsupported | Unsupported | Never shipped a Windows build (zero Windows assets across 66 releases scanned 2026-10-10); setup-ruby hard-fails ([source](https://github.com/ruby/setup-ruby/blob/master/README.md)) |
151
+
135
152
  ### Federated DVCS
136
153
 
137
154
  <details markdown="1">
@@ -289,6 +306,8 @@ GitHub API and CI helpers
289
306
 
290
307
  - `GITHUB_TOKEN` or `GH_TOKEN`: Token used by `ci:act` and release workflow checks to query GitHub Actions status at higher rate limits
291
308
  - `GITLAB_TOKEN` or `GL_TOKEN`: Token used by `ci:act` and CI monitor to query GitLab pipeline status
309
+ - `K_RELEASE_CI_REQUESTS_PER_MINUTE`: GitHub API request budget for the release CI monitor (default 90/min). Requests above the budget are spaced out; an exhausted quota parks the monitor until the reset instead of failing
310
+ - `K_RELEASE_CI_JOBS_POLL_INTERVAL`: Minimum seconds between job-level polls of an in-flight workflow run during release CI monitoring (default 60). Job-level detection catches a failed job in a large matrix before the run concludes, without multiplying API request volume
292
311
 
293
312
  Releasing and signing
294
313
 
@@ -994,7 +1013,6 @@ Thanks for RTFM. ☺️
994
1013
  [🚎jruby-9.4-wf]: https://github.com/kettle-dev/kettle-dev/actions/workflows/jruby-9.4.yml
995
1014
  [🚎jruby-10.0-wf]: https://github.com/kettle-dev/kettle-dev/actions/workflows/jruby-10.0.yml
996
1015
  [🚎truby-22.3-wf]: https://github.com/kettle-dev/kettle-dev/actions/workflows/truffleruby-22.3.yml
997
- [🚎truby-23.0-wf]: https://github.com/kettle-dev/kettle-dev/actions/workflows/truffleruby-23.0.yml
998
1016
  [🚎truby-23.1-wf]: https://github.com/kettle-dev/kettle-dev/actions/workflows/truffleruby-23.1.yml
999
1017
  [🚎truby-24.2-wf]: https://github.com/kettle-dev/kettle-dev/actions/workflows/truffleruby-24.2.yml
1000
1018
  [🚎truby-25.0-wf]: https://github.com/kettle-dev/kettle-dev/actions/workflows/truffleruby-25.0.yml
@@ -1029,13 +1047,11 @@ Thanks for RTFM. ☺️
1029
1047
  [💎ruby-c-i]: https://img.shields.io/badge/Ruby-current-CC342D?style=for-the-badge&logo=ruby&logoColor=green
1030
1048
  [💎ruby-headi]: https://img.shields.io/badge/Ruby-HEAD-CC342D?style=for-the-badge&logo=ruby&logoColor=blue
1031
1049
  [💎truby-22.3i]: https://img.shields.io/badge/Truffle_Ruby-22.3-34BCB1?style=for-the-badge&logo=ruby&logoColor=pink
1032
- [💎truby-23.0i]: https://img.shields.io/badge/Truffle_Ruby-23.0-34BCB1?style=for-the-badge&logo=ruby&logoColor=pink
1033
1050
  [💎truby-23.1i]: https://img.shields.io/badge/Truffle_Ruby-23.1-34BCB1?style=for-the-badge&logo=ruby&logoColor=pink
1034
1051
  [💎truby-24.2i]: https://img.shields.io/badge/Truffle_Ruby-24.2-34BCB1?style=for-the-badge&logo=ruby&logoColor=pink
1035
1052
  [💎truby-25.0i]: https://img.shields.io/badge/Truffle_Ruby-25.0-34BCB1?style=for-the-badge&logo=ruby&logoColor=pink
1036
1053
  [💎truby-33.0i]: https://img.shields.io/badge/Truffle_Ruby-33.0-34BCB1?style=for-the-badge&logo=ruby&logoColor=pink
1037
1054
  [💎truby-c-i]: https://img.shields.io/badge/Truffle_Ruby-current-34BCB1?style=for-the-badge&logo=ruby&logoColor=green
1038
- [💎truby-headi]: https://img.shields.io/badge/Truffle_Ruby-HEAD-34BCB1?style=for-the-badge&logo=ruby&logoColor=blue
1039
1055
  [💎jruby-9.2i]: https://img.shields.io/badge/JRuby-9.2-FBE742?style=for-the-badge&logo=ruby&logoColor=red
1040
1056
  [💎jruby-9.3i]: https://img.shields.io/badge/JRuby-9.3-FBE742?style=for-the-badge&logo=ruby&logoColor=red
1041
1057
  [💎jruby-9.4i]: https://img.shields.io/badge/JRuby-9.4-FBE742?style=for-the-badge&logo=ruby&logoColor=red
@@ -1068,7 +1084,7 @@ Thanks for RTFM. ☺️
1068
1084
  [📌gitmoji]: https://gitmoji.dev
1069
1085
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
1070
1086
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
1071
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-6.926-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1087
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-7.022-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1072
1088
  [🔐security]: https://github.com/kettle-dev/kettle-dev/blob/main/SECURITY.md
1073
1089
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
1074
1090
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -5,9 +5,23 @@ require "open3"
5
5
  require "net/http"
6
6
  require "json"
7
7
  require "uri"
8
+ require "fileutils"
8
9
 
9
10
  module Kettle
10
11
  module Dev
12
+ # GitHub API rejected the request because the token is invalid/revoked.
13
+ class CIAuthError < StandardError; end
14
+
15
+ # GitHub API quota exhausted. Recoverable: retry after the reset time.
16
+ class CIRateLimitError < StandardError
17
+ attr_reader :reset_at
18
+
19
+ def initialize(message, reset_at: nil)
20
+ @reset_at = reset_at
21
+ super(message)
22
+ end
23
+ end
24
+
11
25
  # CI-related helper functions used by Rake tasks and release tooling.
12
26
  #
13
27
  # This module only exposes module-functions (no instance state) and is
@@ -130,6 +144,13 @@ module Kettle
130
144
  "id" => run["id"],
131
145
  "head_sha" => run["head_sha"]
132
146
  }
147
+ rescue Kettle::Dev::CIAuthError, Kettle::Dev::CIRateLimitError
148
+ # Auth failures are fatal; rate limits are recoverable but must reach
149
+ # the monitor (which parks until the reset) — swallowing either turns
150
+ # the poll into an infinite 0/N tick.
151
+ # fail the same way, so swallowing this turns the monitor into an
152
+ # infinite 0/N tick. Re-raise for the caller to abort with guidance.
153
+ raise
133
154
  rescue => e
134
155
  Kettle::Dev.debug_error(e, __method__)
135
156
  nil
@@ -163,15 +184,140 @@ module Kettle
163
184
  end
164
185
  end
165
186
 
187
+ # Rate-limit budget shared by every CIHelpers caller. The budget is
188
+ # enforced MACHINE-WIDE: parallel family releases (kettle-family spawns
189
+ # one kettle-release process per member job, and every family can run
190
+ # concurrently) all share one GitHub per-token quota, so the limiter
191
+ # coordinates through an flock'd ledger file under XDG state rather than
192
+ # per-process memory. kettle-dev owns the file because it makes the
193
+ # requests; kettle-family never calls the API itself.
194
+ CI_RATE_BUDGET_PER_MINUTE = (ENV["K_RELEASE_CI_REQUESTS_PER_MINUTE"] || 90).to_f
195
+ CI_RATE_MIN_INTERVAL = CI_RATE_BUDGET_PER_MINUTE.positive? ? 60.0 / CI_RATE_BUDGET_PER_MINUTE : 0.0
196
+ @ci_rate_lock = Mutex.new
197
+
198
+ # Ledger location: XDG_STATE_HOME/kettle-dev/ci-rate-budget.json
199
+ # (XDG default is ~/.local/state). Overridable wholesale with
200
+ # K_RELEASE_CI_RATE_STATE_FILE. Read at CALL time (not frozen at load)
201
+ # so test suites can point the limiter at a per-run tmp ledger via
202
+ # stubbed_env: the shared ledger is effectively a local production
203
+ # system and specs must never consume or corrupt its state.
204
+ def ci_rate_state_path
205
+ override = ENV["K_RELEASE_CI_RATE_STATE_FILE"]
206
+ return override if override && !override.empty?
207
+
208
+ base = ENV["XDG_STATE_HOME"]
209
+ base = (base && !base.empty?) ? base : File.join(Dir.home, ".local", "state")
210
+ File.join(base, "kettle-dev", "ci-rate-budget.json")
211
+ end
212
+
213
+ # Cross-process spacing, ticket-style: each caller claims the next
214
+ # request slot under an exclusive flock on the ledger file, RELEASES the
215
+ # lock, and only then sleeps out its wait. The critical section is a few
216
+ # microseconds regardless of budget, so hammering claimers queue briefly
217
+ # on the flock instead of each holding it for a whole interval — the old
218
+ # in-lock sleep serialized the whole machine behind one interval per
219
+ # claim, and a claimer wedged mid-sleep deadlocked every other process
220
+ # (observed in CI: a spec-run worker held the flock for minutes while
221
+ # its peers silently starved past the watchdog threshold).
222
+ # The flock is released automatically if a process dies. A missing or
223
+ # corrupt ledger starts fresh.
224
+ def github_rate_limit_wait!
225
+ return if CI_RATE_MIN_INTERVAL.zero?
226
+
227
+ wait = claim_ci_rate_slot
228
+ sleep(wait) if wait.positive?
229
+ end
230
+
231
+ # Returns how long the caller must wait before its request (0.0 when the
232
+ # budget is unspent). Never sleeps itself. Lock hold time is bounded by
233
+ # the file IO only.
234
+ def claim_ci_rate_slot
235
+ @ci_rate_lock.synchronize do
236
+ path = ci_rate_state_path
237
+ dir = File.dirname(path)
238
+ begin
239
+ FileUtils.mkdir_p(dir)
240
+ rescue SystemCallError
241
+ # No writable state dir: degrade to in-process throttling only.
242
+ return ci_rate_claim_in_process
243
+ end
244
+
245
+ begin
246
+ File.open(path, File::RDWR | File::CREAT, 0o644) do |f|
247
+ f.flock(File::LOCK_EX)
248
+ payload = f.size.zero? ? {} : JSON.parse(f.read)
249
+ last = payload["next_slot"] || payload["last_request"] || 0.0
250
+ now = Time.now.to_f
251
+ # Absurdly future (clock skew) or ancient entries start fresh.
252
+ last = 0.0 if !last.is_a?(Numeric) || (last - now).abs > 3600
253
+ slot = [last + CI_RATE_MIN_INTERVAL, now].max
254
+ f.rewind
255
+ f.truncate(0)
256
+ f.write(JSON.generate({"next_slot" => slot}))
257
+ f.flush
258
+ wait = slot - now
259
+ return wait.positive? ? wait : 0.0
260
+ end
261
+ rescue SystemCallError, JSON::ParserError
262
+ # Ledger unusable (read-only FS, broken JSON, no flock support):
263
+ # degrade to in-process throttling rather than failing releases.
264
+ return ci_rate_claim_in_process
265
+ end
266
+ end
267
+ end
268
+
269
+ # Fallback when no shared ledger is available. In-process ticket claims,
270
+ # sized to the same budget; the caller sleeps outside this method.
271
+ # rubocop:disable ThreadSafety/ClassInstanceVariable -- callers hold @ci_rate_lock, so this state has no concurrent writers; the degradation path is intentionally module-scoped.
272
+ def ci_rate_claim_in_process
273
+ @ci_rate_inproc_last = 0.0 unless defined?(@ci_rate_inproc_last)
274
+ now = Time.now.to_f
275
+ slot = [@ci_rate_inproc_last + CI_RATE_MIN_INTERVAL, now].max
276
+ @ci_rate_inproc_last = slot
277
+ wait = slot - now
278
+ wait.positive? ? wait : 0.0
279
+ end
280
+ # rubocop:enable ThreadSafety/ClassInstanceVariable
281
+
166
282
  def github_get_json(url, token:)
283
+ github_rate_limit_wait!
167
284
  uri = URI(url)
168
285
  req = Net::HTTP::Get.new(uri)
169
286
  req["User-Agent"] = "kettle-dev/ci-helpers"
170
287
  req["Authorization"] = "token #{token}" if token && !token.empty?
171
288
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
172
- return unless res.is_a?(Net::HTTPSuccess)
289
+ # Dispatch on is_a? rather than `case`/===: verifying doubles
290
+ # (instance_double(Net::HTTPOK)) used in specs do not reliably match
291
+ # `Net::HTTPSuccess === res`, which silently degraded every stubbed
292
+ # response to "no data".
293
+ if res.is_a?(Net::HTTPSuccess)
294
+ JSON.parse(res.body)
295
+ elsif res.is_a?(Net::HTTPUnauthorized)
296
+ # 401 is not retryable: an invalid or revoked token fails EVERY
297
+ # request, so a monitor treating it as "no data yet" would tick
298
+ # forever at 0/N. Abort with an actionable message.
299
+ raise(
300
+ Kettle::Dev::CIAuthError,
301
+ "GitHub API rejected the request (401: invalid or revoked token) for #{url}. " \
302
+ "Check GITHUB_TOKEN / GH_TOKEN."
303
+ )
304
+ elsif res.is_a?(Net::HTTPForbidden)
305
+ if res["X-RateLimit-Remaining"].to_i.zero?
306
+ # Rate limited: recoverable. Honor the reset time, then let the
307
+ # caller retry the poll cycle.
308
+ reset_at = Time.at(res["X-RateLimit-Reset"].to_i).utc
309
+ raise(
310
+ Kettle::Dev::CIRateLimitError,
311
+ "GitHub API rate limit exceeded; resets at #{reset_at.strftime("%H:%M:%SZ")}"
312
+ )
313
+ end
173
314
 
174
- JSON.parse(res.body)
315
+ # Non-quota 403 (e.g. resource permissions): treat as no data, like
316
+ # other transient responses.
317
+ nil
318
+ else
319
+ nil
320
+ end
175
321
  end
176
322
 
177
323
  # Whether a run has completed successfully.
@@ -215,6 +361,11 @@ module Kettle
215
361
  "html_url" => job["html_url"]
216
362
  }
217
363
  end
364
+ rescue Kettle::Dev::CIAuthError, Kettle::Dev::CIRateLimitError
365
+ # See latest_run: auth/rate-limit failures must abort or park the
366
+ # monitor, not degrade
367
+ # to "no jobs yet".
368
+ raise
218
369
  rescue => e
219
370
  Kettle::Dev.debug_error(e, __method__)
220
371
  []
@@ -50,10 +50,17 @@ module Kettle
50
50
  # @param restart_hint [String] guidance command shown on failure
51
51
  # @return [Boolean] whether all configured CI providers passed
52
52
  def monitor_all!(restart_hint: "bundle exec kettle-release --start-step 10", workflows: nil, keepalive: nil, event_recorder: nil, **options)
53
- results = [
54
- monitor_github_internal!(restart_hint: restart_hint, workflows: workflows, keepalive: keepalive, event_recorder: event_recorder),
55
- monitor_gitlab_internal!(restart_hint: restart_hint, keepalive: keepalive, event_recorder: event_recorder)
56
- ]
53
+ # Rate limiting is recoverable: when the GitHub monitor parks for the
54
+ # quota reset it returns :rate_limited and we re-enter it. GitLab has
55
+ # no equivalent, so only the GitHub result drives the retry.
56
+ results = nil
57
+ loop do
58
+ results = [
59
+ monitor_github_internal!(restart_hint: restart_hint, workflows: workflows, keepalive: keepalive, event_recorder: event_recorder),
60
+ monitor_gitlab_internal!(restart_hint: restart_hint, keepalive: keepalive, event_recorder: event_recorder)
61
+ ]
62
+ break unless results.first == :rate_limited
63
+ end
57
64
  abort("CI configuration not detected (GitHub or GitLab). Ensure CI is configured and remotes point to the correct hosts.") if results.all?(&:nil?)
58
65
 
59
66
  results.all? { |result| result != false }
@@ -343,6 +350,18 @@ module Kettle
343
350
  start_deadline = monotonic_time + start_timeout
344
351
  keepalive_timer = keepalive_timer(keepalive)
345
352
  all_started_emitted = false
353
+ # Job-level failure detection polls one jobs endpoint per in-flight
354
+ # workflow per cycle; on big matrices that multiplies request volume.
355
+ # Gate it to once per K_RELEASE_CI_JOBS_POLL_INTERVAL seconds
356
+ # (default 60) so run-level polling carries the fast loop and jobs
357
+ # checks stay a bounded add-on.
358
+ jobs_poll_interval = begin
359
+ Float(ENV["K_RELEASE_CI_JOBS_POLL_INTERVAL"])
360
+ rescue
361
+ nil
362
+ end
363
+ jobs_poll_interval = (jobs_poll_interval && jobs_poll_interval >= 0) ? jobs_poll_interval : 60.0
364
+ last_jobs_poll = 0.0
346
365
  loop do
347
366
  keepalive_timer.call
348
367
 
@@ -414,7 +433,9 @@ module Kettle
414
433
  # a re-run will have to spend anyway.
415
434
  run_failed = Kettle::Dev::CIHelpers.failed?(run)
416
435
  failed_job = nil
417
- if !passed[wf] && !run_failed && run["status"] != "completed"
436
+ jobs_poll_due = monotonic_time - last_jobs_poll >= jobs_poll_interval
437
+ if !passed[wf] && !run_failed && run["status"] != "completed" && jobs_poll_due
438
+ last_jobs_poll = monotonic_time
418
439
  # Run still in flight: poll its jobs so a single red job in
419
440
  # a large matrix aborts immediately instead of waiting for
420
441
  # the whole workflow to conclude.
@@ -504,6 +525,25 @@ module Kettle
504
525
  puts "\nGitHub workflows completed with failures (#{passed.size}/#{total} passed); continuing because K_RELEASE_CI_CONTINUE=true."
505
526
  end
506
527
  failed.empty?
528
+ rescue Kettle::Dev::CIRateLimitError => error
529
+ # Rate limiting is recoverable: park until the quota resets, emit an
530
+ # event, then re-enter the same monitor (the caller wraps this method
531
+ # in a retry loop keyed on the :rate_limited return value).
532
+ pbar&.finish unless pbar&.finished?
533
+ reset_at = (error.respond_to?(:reset_at) && error.reset_at) ? error.reset_at : (Time.now.utc + 60)
534
+ wait = [(reset_at - Time.now.utc).ceil, 1].max
535
+ puts "\n#{error.message}; waiting #{wait}s before retrying."
536
+ emit_ci_monitor_event(
537
+ event_recorder,
538
+ action: "github_rate_limited",
539
+ status: "started",
540
+ provider: "github",
541
+ reset_at: reset_at.utc.strftime("%H:%M:%SZ"),
542
+ wait_seconds: wait,
543
+ head_sha: head_sha
544
+ )
545
+ sleep(wait)
546
+ :rate_limited
507
547
  end
508
548
  module_function :monitor_github_internal!
509
549
 
@@ -161,6 +161,12 @@ module Kettle
161
161
  platforms = release_platforms_for(paths) if force_full_update
162
162
  uninstall_unreleased_local_gems(paths) if force_full_update
163
163
  paths.each do |path|
164
+ # Re-check existence: in CI the checkout can contain a stale
165
+ # appraisal lock that later steps (or a parallel worker) delete
166
+ # between the lockfile_paths scan and this loop; ENOENT here would
167
+ # abort the whole release pipeline for a file that no longer needs
168
+ # resetting.
169
+ next unless File.file?(path)
164
170
  if force_full_update || normalization_needed?(path, strict: release_lockfiles)
165
171
  reset_lockfile!(
166
172
  path,
@@ -191,6 +197,10 @@ module Kettle
191
197
  end
192
198
 
193
199
  def reset_lockfile!(path, full_update: false, platforms: nil, skip_changelog_dependency: false, update_bundler: false, strict: false)
200
+ # Mirrors the reset loop guard: a lockfile that vanished after the
201
+ # scan (CI cleanup of generated appraisal locks) has nothing to
202
+ # normalize; aborting the whole release for it would be wrong.
203
+ return unless File.file?(path)
194
204
  gemfile = gemfile_for_lockfile(path)
195
205
  unless gemfile && File.file?(gemfile)
196
206
  warn("Cannot reset #{display_path(path)} because its Gemfile was not found.")
@@ -285,7 +295,7 @@ module Kettle
285
295
 
286
296
  def lockfile_platforms(path)
287
297
  in_platforms = false
288
- File.readlines(path).filter_map do |line|
298
+ safe_lockfile_lines(path).filter_map do |line|
289
299
  stripped = line.strip
290
300
  if stripped.match?(/\A[A-Z][A-Z ]*\z/)
291
301
  in_platforms = stripped == "PLATFORMS"
@@ -438,7 +448,7 @@ module Kettle
438
448
  end
439
449
 
440
450
  def local_path_remote_lines(path)
441
- self.class.local_path_remote_lines_from_source(File.read(path))
451
+ self.class.local_path_remote_lines_from_source(safe_lockfile_lines(path).join)
442
452
  end
443
453
 
444
454
  attr_reader :release_graph_contract
@@ -450,7 +460,7 @@ module Kettle
450
460
  end
451
461
 
452
462
  def local_path_remote_at(path, line_number)
453
- File.readlines(path)[line_number - 1].to_s.split("remote:", 2).last.to_s.strip
463
+ safe_lockfile_lines(path)[line_number - 1].to_s.split("remote:", 2).last.to_s.strip
454
464
  end
455
465
 
456
466
  def allowed_local_path?(path)
@@ -467,7 +477,7 @@ module Kettle
467
477
  def empty_registry_checksums(path)
468
478
  non_registry_gems = non_registry_source_gems(path) | path_dependency_gems(path)
469
479
  in_checksums = false
470
- entries = File.readlines(path).filter_map.with_index(1) do |line, index|
480
+ entries = safe_lockfile_lines(path).filter_map.with_index(1) do |line, index|
471
481
  stripped = line.strip
472
482
  if stripped == "CHECKSUMS"
473
483
  in_checksums = true
@@ -507,7 +517,7 @@ module Kettle
507
517
 
508
518
  def has_any_sha_checksum?(path)
509
519
  in_checksums = false
510
- File.readlines(path).any? do |line|
520
+ safe_lockfile_lines(path).any? do |line|
511
521
  stripped = line.strip
512
522
  if stripped == "CHECKSUMS"
513
523
  in_checksums = true
@@ -528,14 +538,14 @@ module Kettle
528
538
  version = bundled_with_version(path)
529
539
  return false if version.empty?
530
540
 
531
- checksums = self.class.checksum_entries_from_source(File.read(path))
541
+ checksums = self.class.checksum_entries_from_source(safe_lockfile_lines(path).join)
532
542
  return false unless checksums&.keys&.any? { |name, _checksum_version| name == "bundler" }
533
543
 
534
544
  !checksums.key?(["bundler", version])
535
545
  end
536
546
 
537
547
  def bundled_with_version(path)
538
- lines = File.readlines(path)
548
+ lines = safe_lockfile_lines(path)
539
549
  marker = lines.index { |line| line.strip == "BUNDLED WITH" }
540
550
  return "" unless marker
541
551
 
@@ -616,7 +626,8 @@ module Kettle
616
626
 
617
627
  def gemfile_bootstrap_gems(gemfile)
618
628
  gemfile_source_paths(gemfile).each_with_index.each_with_object({}) do |(path, index), gems|
619
- calls = require_calls_from_ripper(Ripper.sexp(File.read(path)))
629
+ source = File.file?(path) ? File.read(path) : ""
630
+ calls = require_calls_from_ripper(Ripper.sexp(source))
620
631
  calls.filter_map { |method_name, argument| bootstrap_gem_name(method_name, argument) }.each do |name|
621
632
  gems[name] = gems.fetch(name, false) || index.zero?
622
633
  end
@@ -701,7 +712,7 @@ module Kettle
701
712
 
702
713
  def lockfile_source_content(path)
703
714
  in_checksums = false
704
- File.readlines(path).filter_map do |line|
715
+ safe_lockfile_lines(path).filter_map do |line|
705
716
  stripped = line.strip
706
717
  if stripped == "CHECKSUMS"
707
718
  in_checksums = true
@@ -731,13 +742,18 @@ module Kettle
731
742
  def rebuild_lockfile(path)
732
743
  backup = backup_path_for(path)
733
744
  FileUtils.mkdir_p(File.dirname(backup))
734
- FileUtils.cp(path, backup)
745
+ # TOCTOU: the target can vanish between the scan/entry guard and this
746
+ # copy (CI cleanup of generated appraisal lockfiles mid-process).
747
+ # Nothing to preserve in that case — yield so the rebuild writes a
748
+ # fresh lockfile, and skip the backup restore entirely.
749
+ existed = File.file?(path)
750
+ FileUtils.cp(path, backup) if existed
735
751
  FileUtils.rm_f(path)
736
752
  yield
737
- FileUtils.mv(backup, path, force: true) unless File.file?(path)
753
+ FileUtils.mv(backup, path, force: true) if existed && !File.file?(path)
738
754
  FileUtils.rm_f(backup)
739
755
  rescue
740
- FileUtils.mv(backup, path, force: true) if File.file?(backup)
756
+ FileUtils.mv(backup, path, force: true) if existed && File.file?(backup)
741
757
  raise
742
758
  end
743
759
 
@@ -779,11 +795,22 @@ module Kettle
779
795
  registry_specs_with_lines(path).map { |name, version, _line_number| [name, version] }
780
796
  end
781
797
 
798
+ # Single read path for lockfile contents: a generated appraisal lock
799
+ # can vanish between the lockfile_paths scan and any read (CI workers
800
+ # create and clean up locks in the repo root mid-process). Every reader
801
+ # in this class goes through here and treats absence as an empty file
802
+ # so a deleted ghost lock degrades to "nothing to do" instead of
803
+ # crashing the release pipeline with Errno::ENOENT.
804
+ def safe_lockfile_lines(path)
805
+ return [].freeze unless File.file?(path)
806
+ File.readlines(path)
807
+ end
808
+
782
809
  def registry_specs_with_lines(path)
783
810
  specs = []
784
811
  in_gem = false
785
812
  in_specs = false
786
- File.readlines(path).each.with_index(1) do |line, line_number|
813
+ safe_lockfile_lines(path).each.with_index(1) do |line, line_number|
787
814
  stripped = line.chomp
788
815
  if stripped == "GEM"
789
816
  in_gem = true
@@ -852,7 +879,7 @@ module Kettle
852
879
  def registry_source_urls(path)
853
880
  urls = []
854
881
  in_gem = false
855
- File.readlines(path).each do |line|
882
+ safe_lockfile_lines(path).each do |line|
856
883
  stripped = line.strip
857
884
  if stripped == "GEM"
858
885
  in_gem = true
@@ -492,6 +492,7 @@ module Kettle
492
492
  # 14. build
493
493
  with_release_resume_step(14) do
494
494
  if run_step?(14)
495
+ normalize_release_lockfiles_before_publish_tasks!
495
496
  ensure_release_secrets_ready_for_signing! if signing_enabled? && release_secrets_configured?
496
497
  if signing_enabled? && release_secrets_configured?
497
498
  puts "Running build with gem signing passphrase from configured secrets provider (#{release_secrets_provider_label})..."
@@ -505,6 +506,7 @@ module Kettle
505
506
  # 15. release and tag
506
507
  with_release_resume_step(15) do
507
508
  if run_step?(15)
509
+ normalize_release_lockfiles_before_publish_tasks!
508
510
  version ||= detect_version
509
511
  gem_name = detect_gem_name
510
512
  @release_candidate = build_release_candidate(gem_name, version)
@@ -763,6 +765,27 @@ module Kettle
763
765
  reset_release_lockfiles!(stage: "before release task bundle installs")
764
766
  end
765
767
 
768
+ # When resuming at step 14/15 the step 0-5 lockfile preflight is skipped,
769
+ # but this process's own boot (`bundle exec kettle-release` with the
770
+ # family launcher's local-path toggle active) can silently rewrite the
771
+ # canonical Gemfile.lock with sibling PATH remotes — the gemspec
772
+ # self-version bump makes every boot re-resolve. `rake release` aborts on
773
+ # the dirty worktree at release:guard_clean. Normalize at the publish
774
+ # boundary too: once per run, invoked from BOTH the step 14 and step 15
775
+ # entry points so a `--start-step 15` resume (which skips step 14) is
776
+ # covered as well. Projects without any tracked release lockfile (bare
777
+ # scratch specs) have nothing to normalize; LockfileReset would abort.
778
+ def normalize_release_lockfiles_before_publish_tasks!
779
+ return if @release_lockfiles_normalized_before_publish
780
+ return if lockfile_reset.lockfile_paths.empty?
781
+
782
+ reset_release_lockfiles!(stage: "before publish tasks")
783
+ validate_release_lockfiles!(stage: "before publish tasks")
784
+ prepare_release_lockfiles_for_release_tasks!
785
+ materialize_release_lockfiles_for_release_tasks!
786
+ @release_lockfiles_normalized_before_publish = true
787
+ end
788
+
766
789
  # Resetting can select a platform-specific artifact that is not installed
767
790
  # in the invoking development bundle. Materialize every canonical release
768
791
  # bundle before a release gate runs `bundle exec`. Use a disposable copy:
@@ -3371,6 +3394,7 @@ module Kettle
3371
3394
  prerelease: false
3372
3395
  })
3373
3396
 
3397
+ Kettle::Dev::CIHelpers.github_rate_limit_wait!
3374
3398
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
3375
3399
  http.request(req)
3376
3400
  end
@@ -3441,6 +3465,7 @@ module Kettle
3441
3465
  request["Accept"] = "application/vnd.github+json"
3442
3466
  request["Authorization"] = "token #{token}"
3443
3467
  request["User-Agent"] = "kettle-dev-release-cli"
3468
+ Kettle::Dev::CIHelpers.github_rate_limit_wait!
3444
3469
  response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
3445
3470
  return [JSON.parse(response.body), nil] if response.is_a?(Net::HTTPSuccess)
3446
3471
 
@@ -3540,6 +3565,7 @@ module Kettle
3540
3565
  request["Authorization"] = "token #{token}"
3541
3566
  request["User-Agent"] = "kettle-dev-release-cli"
3542
3567
  request.body = JSON.dump(name: title, body: body)
3568
+ Kettle::Dev::CIHelpers.github_rate_limit_wait!
3543
3569
  response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
3544
3570
  response.is_a?(Net::HTTPSuccess) ? [true, "updated"] : [false, "HTTP #{response.code}: #{response.body}"]
3545
3571
  rescue => e
@@ -68,6 +68,8 @@ module Kettle
68
68
  token = Kettle::Dev::CIHelpers.default_token
69
69
  req["Authorization"] = "token #{token}" if token && !token.empty?
70
70
  begin
71
+ # Share the machine-wide GitHub API budget with release monitors.
72
+ Kettle::Dev::CIHelpers.github_rate_limit_wait!
71
73
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
72
74
  if res.is_a?(Net::HTTPSuccess)
73
75
  data = JSON.parse(res.body)
@@ -299,6 +301,8 @@ module Kettle
299
301
  req = Net::HTTP::Get.new(uri)
300
302
  req["User-Agent"] = "ci:act rake task"
301
303
  req["Authorization"] = "token #{tk}" if tk && !tk.empty?
304
+ # Share the machine-wide GitHub API budget with release monitors.
305
+ Kettle::Dev::CIHelpers.github_rate_limit_wait!
302
306
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
303
307
  if res.is_a?(Net::HTTPSuccess)
304
308
  data = JSON.parse(res.body)
@@ -5,7 +5,7 @@ module Kettle
5
5
  # Version namespace for this gem.
6
6
  module Version
7
7
  # Current gem version.
8
- VERSION = "3.1.11"
8
+ VERSION = "3.1.13"
9
9
  end
10
10
  # Current gem version exposed at the traditional constant location.
11
11
  VERSION = Version::VERSION # Traditional Constant Location
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kettle-dev
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.11
4
+ version: 3.1.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -120,7 +120,7 @@ dependencies:
120
120
  version: '2.0'
121
121
  - - ">="
122
122
  - !ruby/object:Gem::Version
123
- version: 2.0.24
123
+ version: 2.0.25
124
124
  type: :runtime
125
125
  prerelease: false
126
126
  version_requirements: !ruby/object:Gem::Requirement
@@ -130,7 +130,7 @@ dependencies:
130
130
  version: '2.0'
131
131
  - - ">="
132
132
  - !ruby/object:Gem::Version
133
- version: 2.0.24
133
+ version: 2.0.25
134
134
  - !ruby/object:Gem::Dependency
135
135
  name: ruby-progressbar
136
136
  requirement: !ruby/object:Gem::Requirement
@@ -276,7 +276,7 @@ dependencies:
276
276
  version: '3.2'
277
277
  - - ">="
278
278
  - !ruby/object:Gem::Version
279
- version: 3.2.14
279
+ version: 3.2.17
280
280
  type: :development
281
281
  prerelease: false
282
282
  version_requirements: !ruby/object:Gem::Requirement
@@ -286,7 +286,7 @@ dependencies:
286
286
  version: '3.2'
287
287
  - - ">="
288
288
  - !ruby/object:Gem::Version
289
- version: 3.2.14
289
+ version: 3.2.17
290
290
  - !ruby/object:Gem::Dependency
291
291
  name: stone_checksums
292
292
  requirement: !ruby/object:Gem::Requirement
@@ -422,10 +422,10 @@ licenses:
422
422
  - AGPL-3.0-only
423
423
  metadata:
424
424
  homepage_uri: https://kettle-dev.galtzo.com
425
- source_code_uri: https://github.com/kettle-dev/kettle-dev/tree/v3.1.11
426
- changelog_uri: https://github.com/kettle-dev/kettle-dev/blob/v3.1.11/CHANGELOG.md
425
+ source_code_uri: https://github.com/kettle-dev/kettle-dev/tree/v3.1.13
426
+ changelog_uri: https://github.com/kettle-dev/kettle-dev/blob/v3.1.13/CHANGELOG.md
427
427
  bug_tracker_uri: https://github.com/kettle-dev/kettle-dev/issues
428
- documentation_uri: https://www.rubydoc.info/gems/kettle-dev/3.1.11
428
+ documentation_uri: https://www.rubydoc.info/gems/kettle-dev/3.1.13
429
429
  funding_uri: https://github.com/sponsors/pboling
430
430
  wiki_uri: https://github.com/kettle-dev/kettle-dev/wiki
431
431
  news_uri: https://www.railsbling.com/tags/kettle-dev
metadata.gz.sig CHANGED
Binary file