kettle-dev 3.1.12 → 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: a53813cd27173724428ba3d36dfc9c49c2cd2602235cc6e67ee4c334ac459f19
4
- data.tar.gz: 259280a12837d7baa9521d1846977d400ed626779f05d45448dd751711d1b11e
3
+ metadata.gz: fee58bbb5088875b1370305934e2816bc7b1f7ad5103776d7877f7bfb8383bd2
4
+ data.tar.gz: 75dd9745eda2bd54c6dcf1a9a5ae9bf8b36fdfcb2edd2e8a3c26ab3bde366aa6
5
5
  SHA512:
6
- metadata.gz: 57d215d343dfbfe520e7d17802afe6ee84cf54ea9a5558924c42c301a43ea768989045e63714fbd4c3b225a57f4b08fe61d3e2aad9f51248d47c8b9d4457d0b8
7
- data.tar.gz: 656b8a321db50a699c0a883c17156c46553cacfc47830de36d2926dfc8bafffab4e83948771692f640325a7474b8a7abec613227e23f321b27ad750aa2d62a6f
6
+ metadata.gz: e21b6253a77ecf67f42b54a464405baa078615cc99585f1146f432135f347f5fc0e7c4044137841f3da5f589486fc3971cd8b5dbe6285bf1f194de9b7bbd8359
7
+ data.tar.gz: 0dd4e28b43e6d4ba4ae3481d0464fbeb8b1c7aa39ae1628f0fc9c2ce80fe9d850a738d62414ceda91f6daf43887ee4b400c6621df7b5b1c473bfaa934ade7941
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,36 @@ 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
+
33
63
  ## [3.1.12] - 2026-10-12
34
64
 
35
65
  - TAG: [v3.1.12][3.1.12t]
@@ -4064,7 +4094,9 @@ Please file a bug if you notice a violation of semantic versioning.
4064
4094
  - Selecting will run the selected workflow via `act`
4065
4095
  - This may move to its own gem in the future.
4066
4096
 
4067
- [Unreleased]: https://github.com/kettle-dev/kettle-dev/compare/v3.1.12...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
4068
4100
  [3.1.12]: https://github.com/kettle-dev/kettle-dev/compare/v3.1.11...v3.1.12
4069
4101
  [3.1.12t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v3.1.12
4070
4102
  [3.1.11]: https://github.com/kettle-dev/kettle-dev/compare/v3.1.10...v3.1.11
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">
@@ -996,7 +1013,6 @@ Thanks for RTFM. ☺️
996
1013
  [🚎jruby-9.4-wf]: https://github.com/kettle-dev/kettle-dev/actions/workflows/jruby-9.4.yml
997
1014
  [🚎jruby-10.0-wf]: https://github.com/kettle-dev/kettle-dev/actions/workflows/jruby-10.0.yml
998
1015
  [🚎truby-22.3-wf]: https://github.com/kettle-dev/kettle-dev/actions/workflows/truffleruby-22.3.yml
999
- [🚎truby-23.0-wf]: https://github.com/kettle-dev/kettle-dev/actions/workflows/truffleruby-23.0.yml
1000
1016
  [🚎truby-23.1-wf]: https://github.com/kettle-dev/kettle-dev/actions/workflows/truffleruby-23.1.yml
1001
1017
  [🚎truby-24.2-wf]: https://github.com/kettle-dev/kettle-dev/actions/workflows/truffleruby-24.2.yml
1002
1018
  [🚎truby-25.0-wf]: https://github.com/kettle-dev/kettle-dev/actions/workflows/truffleruby-25.0.yml
@@ -1031,13 +1047,11 @@ Thanks for RTFM. ☺️
1031
1047
  [💎ruby-c-i]: https://img.shields.io/badge/Ruby-current-CC342D?style=for-the-badge&logo=ruby&logoColor=green
1032
1048
  [💎ruby-headi]: https://img.shields.io/badge/Ruby-HEAD-CC342D?style=for-the-badge&logo=ruby&logoColor=blue
1033
1049
  [💎truby-22.3i]: https://img.shields.io/badge/Truffle_Ruby-22.3-34BCB1?style=for-the-badge&logo=ruby&logoColor=pink
1034
- [💎truby-23.0i]: https://img.shields.io/badge/Truffle_Ruby-23.0-34BCB1?style=for-the-badge&logo=ruby&logoColor=pink
1035
1050
  [💎truby-23.1i]: https://img.shields.io/badge/Truffle_Ruby-23.1-34BCB1?style=for-the-badge&logo=ruby&logoColor=pink
1036
1051
  [💎truby-24.2i]: https://img.shields.io/badge/Truffle_Ruby-24.2-34BCB1?style=for-the-badge&logo=ruby&logoColor=pink
1037
1052
  [💎truby-25.0i]: https://img.shields.io/badge/Truffle_Ruby-25.0-34BCB1?style=for-the-badge&logo=ruby&logoColor=pink
1038
1053
  [💎truby-33.0i]: https://img.shields.io/badge/Truffle_Ruby-33.0-34BCB1?style=for-the-badge&logo=ruby&logoColor=pink
1039
1054
  [💎truby-c-i]: https://img.shields.io/badge/Truffle_Ruby-current-34BCB1?style=for-the-badge&logo=ruby&logoColor=green
1040
- [💎truby-headi]: https://img.shields.io/badge/Truffle_Ruby-HEAD-34BCB1?style=for-the-badge&logo=ruby&logoColor=blue
1041
1055
  [💎jruby-9.2i]: https://img.shields.io/badge/JRuby-9.2-FBE742?style=for-the-badge&logo=ruby&logoColor=red
1042
1056
  [💎jruby-9.3i]: https://img.shields.io/badge/JRuby-9.3-FBE742?style=for-the-badge&logo=ruby&logoColor=red
1043
1057
  [💎jruby-9.4i]: https://img.shields.io/badge/JRuby-9.4-FBE742?style=for-the-badge&logo=ruby&logoColor=red
@@ -1070,7 +1084,7 @@ Thanks for RTFM. ☺️
1070
1084
  [📌gitmoji]: https://gitmoji.dev
1071
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
1072
1086
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
1073
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-6.980-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
1074
1088
  [🔐security]: https://github.com/kettle-dev/kettle-dev/blob/main/SECURITY.md
1075
1089
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
1076
1090
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -5,6 +5,7 @@ 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
@@ -183,30 +184,101 @@ module Kettle
183
184
  end
184
185
  end
185
186
 
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).
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.
191
194
  CI_RATE_BUDGET_PER_MINUTE = (ENV["K_RELEASE_CI_REQUESTS_PER_MINUTE"] || 90).to_f
192
195
  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
196
  @ci_rate_lock = Mutex.new
195
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.
196
224
  def github_rate_limit_wait!
197
225
  return if CI_RATE_MIN_INTERVAL.zero?
198
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
199
235
  @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)
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
205
265
  end
206
- @ci_rate_last_request = now
207
266
  end
208
267
  end
209
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
+
210
282
  def github_get_json(url, token:)
211
283
  github_rate_limit_wait!
212
284
  uri = URI(url)
@@ -295,7 +295,7 @@ module Kettle
295
295
 
296
296
  def lockfile_platforms(path)
297
297
  in_platforms = false
298
- File.readlines(path).filter_map do |line|
298
+ safe_lockfile_lines(path).filter_map do |line|
299
299
  stripped = line.strip
300
300
  if stripped.match?(/\A[A-Z][A-Z ]*\z/)
301
301
  in_platforms = stripped == "PLATFORMS"
@@ -448,7 +448,7 @@ module Kettle
448
448
  end
449
449
 
450
450
  def local_path_remote_lines(path)
451
- 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)
452
452
  end
453
453
 
454
454
  attr_reader :release_graph_contract
@@ -460,7 +460,7 @@ module Kettle
460
460
  end
461
461
 
462
462
  def local_path_remote_at(path, line_number)
463
- 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
464
464
  end
465
465
 
466
466
  def allowed_local_path?(path)
@@ -477,7 +477,7 @@ module Kettle
477
477
  def empty_registry_checksums(path)
478
478
  non_registry_gems = non_registry_source_gems(path) | path_dependency_gems(path)
479
479
  in_checksums = false
480
- 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|
481
481
  stripped = line.strip
482
482
  if stripped == "CHECKSUMS"
483
483
  in_checksums = true
@@ -517,7 +517,7 @@ module Kettle
517
517
 
518
518
  def has_any_sha_checksum?(path)
519
519
  in_checksums = false
520
- File.readlines(path).any? do |line|
520
+ safe_lockfile_lines(path).any? do |line|
521
521
  stripped = line.strip
522
522
  if stripped == "CHECKSUMS"
523
523
  in_checksums = true
@@ -538,14 +538,14 @@ module Kettle
538
538
  version = bundled_with_version(path)
539
539
  return false if version.empty?
540
540
 
541
- checksums = self.class.checksum_entries_from_source(File.read(path))
541
+ checksums = self.class.checksum_entries_from_source(safe_lockfile_lines(path).join)
542
542
  return false unless checksums&.keys&.any? { |name, _checksum_version| name == "bundler" }
543
543
 
544
544
  !checksums.key?(["bundler", version])
545
545
  end
546
546
 
547
547
  def bundled_with_version(path)
548
- lines = File.readlines(path)
548
+ lines = safe_lockfile_lines(path)
549
549
  marker = lines.index { |line| line.strip == "BUNDLED WITH" }
550
550
  return "" unless marker
551
551
 
@@ -626,7 +626,8 @@ module Kettle
626
626
 
627
627
  def gemfile_bootstrap_gems(gemfile)
628
628
  gemfile_source_paths(gemfile).each_with_index.each_with_object({}) do |(path, index), gems|
629
- 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))
630
631
  calls.filter_map { |method_name, argument| bootstrap_gem_name(method_name, argument) }.each do |name|
631
632
  gems[name] = gems.fetch(name, false) || index.zero?
632
633
  end
@@ -711,12 +712,7 @@ module Kettle
711
712
 
712
713
  def lockfile_source_content(path)
713
714
  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)
719
- File.readlines(path).filter_map do |line|
715
+ safe_lockfile_lines(path).filter_map do |line|
720
716
  stripped = line.strip
721
717
  if stripped == "CHECKSUMS"
722
718
  in_checksums = true
@@ -746,13 +742,18 @@ module Kettle
746
742
  def rebuild_lockfile(path)
747
743
  backup = backup_path_for(path)
748
744
  FileUtils.mkdir_p(File.dirname(backup))
749
- 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
750
751
  FileUtils.rm_f(path)
751
752
  yield
752
- FileUtils.mv(backup, path, force: true) unless File.file?(path)
753
+ FileUtils.mv(backup, path, force: true) if existed && !File.file?(path)
753
754
  FileUtils.rm_f(backup)
754
755
  rescue
755
- FileUtils.mv(backup, path, force: true) if File.file?(backup)
756
+ FileUtils.mv(backup, path, force: true) if existed && File.file?(backup)
756
757
  raise
757
758
  end
758
759
 
@@ -794,11 +795,22 @@ module Kettle
794
795
  registry_specs_with_lines(path).map { |name, version, _line_number| [name, version] }
795
796
  end
796
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
+
797
809
  def registry_specs_with_lines(path)
798
810
  specs = []
799
811
  in_gem = false
800
812
  in_specs = false
801
- File.readlines(path).each.with_index(1) do |line, line_number|
813
+ safe_lockfile_lines(path).each.with_index(1) do |line, line_number|
802
814
  stripped = line.chomp
803
815
  if stripped == "GEM"
804
816
  in_gem = true
@@ -867,7 +879,7 @@ module Kettle
867
879
  def registry_source_urls(path)
868
880
  urls = []
869
881
  in_gem = false
870
- File.readlines(path).each do |line|
882
+ safe_lockfile_lines(path).each do |line|
871
883
  stripped = line.strip
872
884
  if stripped == "GEM"
873
885
  in_gem = true
@@ -492,21 +492,7 @@ 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
+ normalize_release_lockfiles_before_publish_tasks!
510
496
  ensure_release_secrets_ready_for_signing! if signing_enabled? && release_secrets_configured?
511
497
  if signing_enabled? && release_secrets_configured?
512
498
  puts "Running build with gem signing passphrase from configured secrets provider (#{release_secrets_provider_label})..."
@@ -520,6 +506,7 @@ module Kettle
520
506
  # 15. release and tag
521
507
  with_release_resume_step(15) do
522
508
  if run_step?(15)
509
+ normalize_release_lockfiles_before_publish_tasks!
523
510
  version ||= detect_version
524
511
  gem_name = detect_gem_name
525
512
  @release_candidate = build_release_candidate(gem_name, version)
@@ -778,6 +765,27 @@ module Kettle
778
765
  reset_release_lockfiles!(stage: "before release task bundle installs")
779
766
  end
780
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
+
781
789
  # Resetting can select a platform-specific artifact that is not installed
782
790
  # in the invoking development bundle. Materialize every canonical release
783
791
  # bundle before a release gate runs `bundle exec`. Use a disposable copy:
@@ -3386,6 +3394,7 @@ module Kettle
3386
3394
  prerelease: false
3387
3395
  })
3388
3396
 
3397
+ Kettle::Dev::CIHelpers.github_rate_limit_wait!
3389
3398
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
3390
3399
  http.request(req)
3391
3400
  end
@@ -3456,6 +3465,7 @@ module Kettle
3456
3465
  request["Accept"] = "application/vnd.github+json"
3457
3466
  request["Authorization"] = "token #{token}"
3458
3467
  request["User-Agent"] = "kettle-dev-release-cli"
3468
+ Kettle::Dev::CIHelpers.github_rate_limit_wait!
3459
3469
  response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
3460
3470
  return [JSON.parse(response.body), nil] if response.is_a?(Net::HTTPSuccess)
3461
3471
 
@@ -3555,6 +3565,7 @@ module Kettle
3555
3565
  request["Authorization"] = "token #{token}"
3556
3566
  request["User-Agent"] = "kettle-dev-release-cli"
3557
3567
  request.body = JSON.dump(name: title, body: body)
3568
+ Kettle::Dev::CIHelpers.github_rate_limit_wait!
3558
3569
  response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
3559
3570
  response.is_a?(Net::HTTPSuccess) ? [true, "updated"] : [false, "HTTP #{response.code}: #{response.body}"]
3560
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.12"
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.12
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.12
426
- changelog_uri: https://github.com/kettle-dev/kettle-dev/blob/v3.1.12/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.12
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