kettle-dev 3.1.11 → 3.1.12

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: a53813cd27173724428ba3d36dfc9c49c2cd2602235cc6e67ee4c334ac459f19
4
+ data.tar.gz: 259280a12837d7baa9521d1846977d400ed626779f05d45448dd751711d1b11e
5
5
  SHA512:
6
- metadata.gz: 5bf4bd022743bdac03fc5806ff9598781abf17814c14ec9cddd5a917d33af628055dea5adbdbf11ee3d4284aa9f5dc2926c8456dab0710fc59ea7093aa23d457
7
- data.tar.gz: a9aa5312d72d80c8868becdb10cbf8e11f57ae4df5e37edc78f781656873b4dea9bc1fd48aca064a4ec63e76ee2156dfe05626e8efa90743a7ffda4b24a397bd
6
+ metadata.gz: 57d215d343dfbfe520e7d17802afe6ee84cf54ea9a5558924c42c301a43ea768989045e63714fbd4c3b225a57f4b08fe61d3e2aad9f51248d47c8b9d4457d0b8
7
+ data.tar.gz: 656b8a321db50a699c0a883c17156c46553cacfc47830de36d2926dfc8bafffab4e83948771692f640325a7474b8a7abec613227e23f321b27ad750aa2d62a6f
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,21 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [3.1.12] - 2026-10-12
34
+
35
+ - TAG: [v3.1.12][3.1.12t]
36
+ - COVERAGE: 91.38% -- 6378/6980 lines in 49 files
37
+ - BRANCH COVERAGE: 75.57% -- 2437/3225 branches in 49 files
38
+ - 52.49% documented
39
+
40
+ ### Fixed
41
+
42
+ - 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
43
+
44
+ - 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
45
+
46
+ - 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
47
+
33
48
  ## [3.1.11] - 2026-10-11
34
49
 
35
50
  - TAG: [v3.1.11][3.1.11t]
@@ -4049,7 +4064,9 @@ Please file a bug if you notice a violation of semantic versioning.
4049
4064
  - Selecting will run the selected workflow via `act`
4050
4065
  - This may move to its own gem in the future.
4051
4066
 
4052
- [Unreleased]: https://github.com/kettle-dev/kettle-dev/compare/v3.1.11...HEAD
4067
+ [Unreleased]: https://github.com/kettle-dev/kettle-dev/compare/v3.1.12...HEAD
4068
+ [3.1.12]: https://github.com/kettle-dev/kettle-dev/compare/v3.1.11...v3.1.12
4069
+ [3.1.12t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v3.1.12
4053
4070
  [3.1.11]: https://github.com/kettle-dev/kettle-dev/compare/v3.1.10...v3.1.11
4054
4071
  [3.1.11t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v3.1.11
4055
4072
  [3.1.10]: https://github.com/kettle-dev/kettle-dev/compare/v3.1.9...v3.1.10
data/README.md CHANGED
@@ -289,6 +289,8 @@ GitHub API and CI helpers
289
289
 
290
290
  - `GITHUB_TOKEN` or `GH_TOKEN`: Token used by `ci:act` and release workflow checks to query GitHub Actions status at higher rate limits
291
291
  - `GITLAB_TOKEN` or `GL_TOKEN`: Token used by `ci:act` and CI monitor to query GitLab pipeline status
292
+ - `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
293
+ - `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
294
 
293
295
  Releasing and signing
294
296
 
@@ -1068,7 +1070,7 @@ Thanks for RTFM. ☺️
1068
1070
  [📌gitmoji]: https://gitmoji.dev
1069
1071
  [📌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
1072
  [🧮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
1073
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-6.980-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1072
1074
  [🔐security]: https://github.com/kettle-dev/kettle-dev/blob/main/SECURITY.md
1073
1075
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
1074
1076
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -8,6 +8,19 @@ require "uri"
8
8
 
9
9
  module Kettle
10
10
  module Dev
11
+ # GitHub API rejected the request because the token is invalid/revoked.
12
+ class CIAuthError < StandardError; end
13
+
14
+ # GitHub API quota exhausted. Recoverable: retry after the reset time.
15
+ class CIRateLimitError < StandardError
16
+ attr_reader :reset_at
17
+
18
+ def initialize(message, reset_at: nil)
19
+ @reset_at = reset_at
20
+ super(message)
21
+ end
22
+ end
23
+
11
24
  # CI-related helper functions used by Rake tasks and release tooling.
12
25
  #
13
26
  # This module only exposes module-functions (no instance state) and is
@@ -130,6 +143,13 @@ module Kettle
130
143
  "id" => run["id"],
131
144
  "head_sha" => run["head_sha"]
132
145
  }
146
+ rescue Kettle::Dev::CIAuthError, Kettle::Dev::CIRateLimitError
147
+ # Auth failures are fatal; rate limits are recoverable but must reach
148
+ # the monitor (which parks until the reset) — swallowing either turns
149
+ # the poll into an infinite 0/N tick.
150
+ # fail the same way, so swallowing this turns the monitor into an
151
+ # infinite 0/N tick. Re-raise for the caller to abort with guidance.
152
+ raise
133
153
  rescue => e
134
154
  Kettle::Dev.debug_error(e, __method__)
135
155
  nil
@@ -163,15 +183,69 @@ module Kettle
163
183
  end
164
184
  end
165
185
 
186
+ # Process-wide rate-limit state shared by every CIHelpers caller in this
187
+ # process (the release runner polls many member/monitor children in one
188
+ # process tree, and each child gets its own copy — this throttles within
189
+ # a process; the budget is sized so even a fully parallel family run
190
+ # stays inside GitHub's per-token allowance).
191
+ CI_RATE_BUDGET_PER_MINUTE = (ENV["K_RELEASE_CI_REQUESTS_PER_MINUTE"] || 90).to_f
192
+ CI_RATE_MIN_INTERVAL = CI_RATE_BUDGET_PER_MINUTE.positive? ? 60.0 / CI_RATE_BUDGET_PER_MINUTE : 0.0
193
+ @ci_rate_last_request = 0.0
194
+ @ci_rate_lock = Mutex.new
195
+
196
+ def github_rate_limit_wait!
197
+ return if CI_RATE_MIN_INTERVAL.zero?
198
+
199
+ @ci_rate_lock.synchronize do
200
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
201
+ delta = now - @ci_rate_last_request
202
+ if delta < CI_RATE_MIN_INTERVAL
203
+ sleep(CI_RATE_MIN_INTERVAL - delta)
204
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
205
+ end
206
+ @ci_rate_last_request = now
207
+ end
208
+ end
209
+
166
210
  def github_get_json(url, token:)
211
+ github_rate_limit_wait!
167
212
  uri = URI(url)
168
213
  req = Net::HTTP::Get.new(uri)
169
214
  req["User-Agent"] = "kettle-dev/ci-helpers"
170
215
  req["Authorization"] = "token #{token}" if token && !token.empty?
171
216
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
172
- return unless res.is_a?(Net::HTTPSuccess)
217
+ # Dispatch on is_a? rather than `case`/===: verifying doubles
218
+ # (instance_double(Net::HTTPOK)) used in specs do not reliably match
219
+ # `Net::HTTPSuccess === res`, which silently degraded every stubbed
220
+ # response to "no data".
221
+ if res.is_a?(Net::HTTPSuccess)
222
+ JSON.parse(res.body)
223
+ elsif res.is_a?(Net::HTTPUnauthorized)
224
+ # 401 is not retryable: an invalid or revoked token fails EVERY
225
+ # request, so a monitor treating it as "no data yet" would tick
226
+ # forever at 0/N. Abort with an actionable message.
227
+ raise(
228
+ Kettle::Dev::CIAuthError,
229
+ "GitHub API rejected the request (401: invalid or revoked token) for #{url}. " \
230
+ "Check GITHUB_TOKEN / GH_TOKEN."
231
+ )
232
+ elsif res.is_a?(Net::HTTPForbidden)
233
+ if res["X-RateLimit-Remaining"].to_i.zero?
234
+ # Rate limited: recoverable. Honor the reset time, then let the
235
+ # caller retry the poll cycle.
236
+ reset_at = Time.at(res["X-RateLimit-Reset"].to_i).utc
237
+ raise(
238
+ Kettle::Dev::CIRateLimitError,
239
+ "GitHub API rate limit exceeded; resets at #{reset_at.strftime("%H:%M:%SZ")}"
240
+ )
241
+ end
173
242
 
174
- JSON.parse(res.body)
243
+ # Non-quota 403 (e.g. resource permissions): treat as no data, like
244
+ # other transient responses.
245
+ nil
246
+ else
247
+ nil
248
+ end
175
249
  end
176
250
 
177
251
  # Whether a run has completed successfully.
@@ -215,6 +289,11 @@ module Kettle
215
289
  "html_url" => job["html_url"]
216
290
  }
217
291
  end
292
+ rescue Kettle::Dev::CIAuthError, Kettle::Dev::CIRateLimitError
293
+ # See latest_run: auth/rate-limit failures must abort or park the
294
+ # monitor, not degrade
295
+ # to "no jobs yet".
296
+ raise
218
297
  rescue => e
219
298
  Kettle::Dev.debug_error(e, __method__)
220
299
  []
@@ -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.")
@@ -701,6 +711,11 @@ module Kettle
701
711
 
702
712
  def lockfile_source_content(path)
703
713
  in_checksums = false
714
+ # A lockfile can vanish between the lockfile_paths scan and this read
715
+ # (CI workers clean up generated appraisal locks mid-process). Treat a
716
+ # missing lockfile as an empty graph: bootstrap version lookups return
717
+ # nil and the reset skips what no longer exists.
718
+ return [].freeze unless File.file?(path)
704
719
  File.readlines(path).filter_map do |line|
705
720
  stripped = line.strip
706
721
  if stripped == "CHECKSUMS"
@@ -492,6 +492,21 @@ module Kettle
492
492
  # 14. build
493
493
  with_release_resume_step(14) do
494
494
  if run_step?(14)
495
+ # When resuming at step 14/15 the step 0-5 lockfile preflight is
496
+ # skipped, but this process's own boot (`bundle exec
497
+ # kettle-release` with the family launcher's local-path toggle
498
+ # active) can silently rewrite the canonical Gemfile.lock with
499
+ # sibling PATH remotes — the gemspec self-version bump makes every
500
+ # boot re-resolve. `rake release` aborts on the dirty worktree at
501
+ # release:guard_clean. Normalize at the publish boundary too.
502
+ # Projects without any tracked release lockfile (bare scratch
503
+ # specs) have nothing to normalize; LockfileReset would abort.
504
+ unless lockfile_reset.lockfile_paths.empty?
505
+ reset_release_lockfiles!(stage: "before publish tasks")
506
+ validate_release_lockfiles!(stage: "before publish tasks")
507
+ prepare_release_lockfiles_for_release_tasks!
508
+ materialize_release_lockfiles_for_release_tasks!
509
+ end
495
510
  ensure_release_secrets_ready_for_signing! if signing_enabled? && release_secrets_configured?
496
511
  if signing_enabled? && release_secrets_configured?
497
512
  puts "Running build with gem signing passphrase from configured secrets provider (#{release_secrets_provider_label})..."
@@ -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.12"
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.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -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.12
426
+ changelog_uri: https://github.com/kettle-dev/kettle-dev/blob/v3.1.12/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.12
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