kettle-dev 3.0.33 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0b611b573a644bc27585a4f82cf71f8d66753af9b5f6035292e051ed67f684d3
4
- data.tar.gz: 15f5ffe131324656a00be51f91a8d68aacca31152f2051a0b0338ee9dc6e0742
3
+ metadata.gz: b7fdd37cea8287569cfe883f3b0830022afe7e90be2582adf2046cadcdd687c7
4
+ data.tar.gz: 327f6277c15a6e780001e321192b72e5a10afc56718171e3324e24445c0466b5
5
5
  SHA512:
6
- metadata.gz: '088b91253bd99bc0b7b02a526f65b2ad117cd3d5a4f15c0d50f19c42bd5829b9b0c5b2ee9db7957f1cfa9f6662d213f02f5b3f8771f150b4e7816d6bbf6c210e'
7
- data.tar.gz: 394dd243472f9d6ad45489eeb00af4084069103ea5280f567bc5f1772d9f4ddf71e233c997907ad22d0c39aad95b4de8bbdbdb5c007f4e67ad1349856155c05e
6
+ metadata.gz: f7cb591d2c7e73f063f11362091eb7efc51ff3d9b6a5cd4242260e3dd45fe1ffce867a77e218f606880f7cfd7bf5fd1f071391714b8164721c89860d4b63c737
7
+ data.tar.gz: 99531967a52f754cbda4a1d868e216de15d1dac6e6d27d1b4028dad9dae87681b1a88761f91d0931425d050f9ae0a6a18344c19f48621dfccac346f9a539c9ec
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,32 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [3.1.0] - 2026-09-10
34
+
35
+ - TAG: [v3.1.0][3.1.0t]
36
+ - COVERAGE: 91.86% -- 6141/6685 lines in 48 files
37
+ - BRANCH COVERAGE: 76.10% -- 2337/3071 branches in 48 files
38
+ - 51.02% documented
39
+
40
+ ### Fixed
41
+
42
+ - [kc] release-graph-contracts: Honor the serialized release graph for lockfile normalization and child commands, retaining only declared CI-resident monorepo paths.
43
+
44
+ - Validate serialized family release graph contracts in release lockfile normalization and child commands instead of reconstructing local-path policy from ambient environment.
45
+
46
+ ## [3.0.34] - 2026-09-09
47
+
48
+ - TAG: [v3.0.34][3.0.34t]
49
+ - COVERAGE: 91.95% -- 6101/6635 lines in 47 files
50
+ - BRANCH COVERAGE: 76.43% -- 2332/3051 branches in 47 files
51
+ - 50.23% documented
52
+
53
+ ### Fixed
54
+
55
+ - Detect and reset release lockfiles whose BUNDLED WITH version lacks a matching Bundler checksum.
56
+
57
+ - Declare the intentional version_gem runtime-heads dependency overlap for templating self-tests.
58
+
33
59
  ## [3.0.33] - 2026-09-07
34
60
 
35
61
  - TAG: [v3.0.33][3.0.33t]
@@ -3834,7 +3860,11 @@ Please file a bug if you notice a violation of semantic versioning.
3834
3860
  - Selecting will run the selected workflow via `act`
3835
3861
  - This may move to its own gem in the future.
3836
3862
 
3837
- [Unreleased]: https://github.com/kettle-dev/kettle-dev/compare/v3.0.33...HEAD
3863
+ [Unreleased]: https://github.com/kettle-dev/kettle-dev/compare/v3.1.0...HEAD
3864
+ [3.1.0]: https://github.com/kettle-dev/kettle-dev/compare/v3.0.34...v3.1.0
3865
+ [3.1.0t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v3.1.0
3866
+ [3.0.34]: https://github.com/kettle-dev/kettle-dev/compare/v3.0.33...v3.0.34
3867
+ [3.0.34t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v3.0.34
3838
3868
  [3.0.33]: https://github.com/kettle-dev/kettle-dev/compare/v3.0.32...v3.0.33
3839
3869
  [3.0.33t]: https://github.com/kettle-dev/kettle-dev/releases/tag/v3.0.33
3840
3870
  [3.0.32]: https://github.com/kettle-dev/kettle-dev/compare/v3.0.31...v3.0.32
data/README.md CHANGED
@@ -1066,7 +1066,7 @@ Thanks for RTFM. ☺️
1066
1066
  [📌gitmoji]: https://gitmoji.dev
1067
1067
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
1068
1068
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
1069
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-6.613-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1069
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-6.685-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1070
1070
  [🔐security]: https://github.com/kettle-dev/kettle-dev/blob/main/SECURITY.md
1071
1071
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
1072
1072
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -9,6 +9,7 @@ require "bundler"
9
9
  require "ripper"
10
10
 
11
11
  require_relative "bundler_env_guard"
12
+ require_relative "release_graph_contract"
12
13
 
13
14
  module Kettle
14
15
  module Dev
@@ -22,8 +23,6 @@ module Kettle
22
23
  "GALTZO_FLOSS_DEV" => "false",
23
24
  "UR_BRAIN_DEV" => "false"
24
25
  }.freeze
25
- ALLOWED_LOCAL_PATH_ROOTS_ENV = "KETTLE_RELEASE_ALLOWED_LOCAL_PATH_ROOTS"
26
- ALLOWED_LOCAL_PATH_ENVS_ENV = "KETTLE_RELEASE_ALLOWED_LOCAL_PATH_ENVS"
27
26
  RELEASE_LOCKFILES_TARGET = "release-lockfiles"
28
27
  SUPPORTED_TARGETS = ["Gemfile.lock", "Appraisal.root.gemfile.lock", RELEASE_LOCKFILES_TARGET].freeze
29
28
  UNBUNDLED_ENV_KEYS = (BundlerEnvGuard::RESET_ENV_KEYS + %w[
@@ -113,28 +112,30 @@ module Kettle
113
112
  @root = root
114
113
  @command_runner = command_runner
115
114
  @release_platforms = {}
116
- @allowed_local_path_roots = configured_allowed_local_path_roots
117
- @allowed_local_path_env_names = configured_allowed_local_path_env_names
115
+ @release_graph_contract = ReleaseGraphContract.from_environment(root: root)
118
116
  end
119
117
 
120
118
  def reset(target, skip_changelog_dependency: false)
121
119
  BundlerEnvGuard.warn_unexpected_env!
122
120
  paths = lockfile_paths_for(target)
123
- # A monorepo lockfile may deliberately use paths rooted in the
124
- # repository's members directory. Keep that valid development graph;
125
- # only rebuild when an unapproved path or other diagnostic remains.
126
- force_full_update = release_lockfiles_target?(target) &&
127
- (allowed_local_path_roots.empty? || paths.any? { |path| normalization_needed?(path) })
121
+ release_lockfiles = release_lockfiles_target?(target)
122
+ # The selected graph governs both tracked release locks and disposable
123
+ # release-task locks. Registry contracts reject every PATH source;
124
+ # CI-resident monorepo contracts retain only declared contained paths.
125
+ force_full_update = release_lockfiles && (
126
+ release_graph_contract.registry_only? || paths.any? { |path| normalization_needed?(path, strict: true) }
127
+ )
128
128
  platforms = release_platforms_for(paths) if force_full_update
129
129
  uninstall_unreleased_local_gems(paths) if force_full_update
130
130
  paths.each do |path|
131
- if force_full_update || normalization_needed?(path)
131
+ if force_full_update || normalization_needed?(path, strict: release_lockfiles)
132
132
  reset_lockfile!(
133
133
  path,
134
134
  full_update: force_full_update,
135
135
  platforms: platforms&.fetch(path),
136
136
  skip_changelog_dependency: skip_changelog_dependency,
137
- update_bundler: force_full_update
137
+ update_bundler: force_full_update,
138
+ strict: release_lockfiles
138
139
  )
139
140
  end
140
141
  end
@@ -145,17 +146,18 @@ module Kettle
145
146
  full_update: true,
146
147
  platforms: platforms.fetch(path),
147
148
  skip_changelog_dependency: skip_changelog_dependency,
148
- update_bundler: true
149
+ update_bundler: true,
150
+ strict: release_lockfiles
149
151
  )
150
152
  end
151
153
  end
152
- diagnostics = paths.flat_map { |path| diagnostics(path) }
154
+ diagnostics = paths.flat_map { |path| diagnostics(path, strict: release_lockfiles) }
153
155
  raise Error, validation_message(target, diagnostics) unless diagnostics.empty?
154
156
 
155
157
  paths
156
158
  end
157
159
 
158
- def reset_lockfile!(path, full_update: false, platforms: nil, skip_changelog_dependency: false, update_bundler: false)
160
+ def reset_lockfile!(path, full_update: false, platforms: nil, skip_changelog_dependency: false, update_bundler: false, strict: false)
159
161
  gemfile = gemfile_for_lockfile(path)
160
162
  unless gemfile && File.file?(gemfile)
161
163
  warn("Cannot reset #{display_path(path)} because its Gemfile was not found.")
@@ -171,9 +173,10 @@ module Kettle
171
173
  platforms: platforms,
172
174
  skip_changelog_dependency: skip_changelog_dependency,
173
175
  update_bundler: update_bundler,
174
- isolated_gem_home: gem_home
176
+ isolated_gem_home: gem_home,
177
+ strict: strict
175
178
  )
176
- if full_update || has_local_path_remote?(path)
179
+ if full_update || has_local_path_remote?(path, strict: strict)
177
180
  rebuild_lockfile(path) { command_runner.call(command) }
178
181
  else
179
182
  command_runner.call(command)
@@ -181,11 +184,11 @@ module Kettle
181
184
  end
182
185
  end
183
186
 
184
- def reset_command(path:, gemfile:, full_update: false, platforms: nil, skip_changelog_dependency: false, update_bundler: false, isolated_gem_home: nil, isolated_gem_path: nil)
185
- update_gems = (full_update || has_local_path_remote?(path)) ? [] : reset_update_gems(path)
187
+ def reset_command(path:, gemfile:, full_update: false, platforms: nil, skip_changelog_dependency: false, update_bundler: false, isolated_gem_home: nil, isolated_gem_path: nil, strict: false)
188
+ update_gems = (full_update || has_local_path_remote?(path, strict: strict)) ? [] : reset_update_gems(path)
186
189
  platforms ||= reset_platforms(path)
187
190
  removed_platforms = full_update ? [] : lockfile_platforms(path) - platforms
188
- env = normalization_env.merge(
191
+ env = (strict ? release_normalization_env : normalization_env).merge(
189
192
  "BUNDLE_GEMFILE" => gemfile,
190
193
  "BUNDLE_LOCKFILE" => path
191
194
  )
@@ -194,6 +197,29 @@ module Kettle
194
197
  env["GEM_HOME"] = isolated_gem_home
195
198
  env["GEM_PATH"] = isolated_gem_path || isolated_gem_home
196
199
  end
200
+ command_prefix = bundler_command_prefix(env)
201
+ command = +"#{command_prefix} bundle lock"
202
+ # Bundler accepts each platform option once followed by a list. Repeating
203
+ # either option silently keeps only the final occurrence with Bundler 4.
204
+ command << " --remove-platform #{removed_platforms.sort.map { |platform| Shellwords.escape(platform) }.join(" ")}" unless removed_platforms.empty?
205
+ command << " --add-platform #{platforms.map { |platform| Shellwords.escape(platform) }.join(" ")}" unless platforms.empty?
206
+ command << " --update"
207
+ command << " #{update_gems.map { |gem_name| Shellwords.escape(gem_name) }.join(" ")}" unless update_gems.empty?
208
+ command << " --add-checksums" unless update_bundler
209
+ return command unless update_bundler
210
+
211
+ # `bundle lock --bundler` accepts a requirement and retains a locked
212
+ # prerelease that satisfies it. `bundle update --bundler=VERSION`
213
+ # records the exact Bundler that is executing the reset instead.
214
+ # Finish with `bundle lock --add-checksums`, since `bundle update`
215
+ # does not support that option.
216
+ bundler_version = Shellwords.escape(Bundler::VERSION)
217
+ command << " && #{command_prefix} bundle update --bundler=#{bundler_version}"
218
+ command << " && #{command_prefix} bundle lock --add-checksums"
219
+ command
220
+ end
221
+
222
+ def bundler_command_prefix(env)
197
223
  command = +"env"
198
224
  UNBUNDLED_ENV_KEYS.each do |key|
199
225
  command << " -u #{key}"
@@ -201,19 +227,6 @@ module Kettle
201
227
  env.each do |key, value|
202
228
  command << " #{key}=#{Shellwords.escape(value)}"
203
229
  end
204
- command << " bundle lock"
205
- # Bundler accepts each platform option once followed by a list. Repeating
206
- # either option silently keeps only the final occurrence with Bundler 4.
207
- command << " --remove-platform #{removed_platforms.sort.map { |platform| Shellwords.escape(platform) }.join(" ")}" unless removed_platforms.empty?
208
- command << " --add-platform #{platforms.map { |platform| Shellwords.escape(platform) }.join(" ")}" unless platforms.empty?
209
- command << " --update"
210
- command << " #{update_gems.map { |gem_name| Shellwords.escape(gem_name) }.join(" ")}" unless update_gems.empty?
211
- # Bundler preserves BUNDLED WITH during a normal lockfile update.
212
- # Release lockfiles must record the Bundler that performed the final
213
- # reset, or the next release command can dirty them after the prep
214
- # commit by reconciling to a newer installed Bundler.
215
- command << " --bundler" if update_bundler
216
- command << " --add-checksums"
217
230
  command
218
231
  end
219
232
 
@@ -253,11 +266,14 @@ module Kettle
253
266
  ).uniq.sort
254
267
  end
255
268
 
256
- def normalization_needed?(path)
257
- has_local_path_remote?(path) || !empty_registry_checksums(path).empty? || !unreleased_workspace_registry_specs(path).empty?
269
+ def normalization_needed?(path, strict: false)
270
+ has_local_path_remote?(path, strict: strict) ||
271
+ !empty_registry_checksums(path).empty? ||
272
+ !unreleased_workspace_registry_specs(path).empty? ||
273
+ bundled_with_checksum_mismatch?(path)
258
274
  end
259
275
 
260
- def diagnostics(path)
276
+ def diagnostics(path, strict: false)
261
277
  diagnostics = []
262
278
  disallowed_local_path_remote_lines(path).each do |line_number|
263
279
  diagnostics << "#{display_path(path)} has local path remote at line #{line_number}"
@@ -268,6 +284,9 @@ module Kettle
268
284
  unreleased_workspace_registry_specs(path).each do |name, version, line_number|
269
285
  diagnostics << "#{display_path(path)} locks local workspace gem #{name} #{version} as a registry gem, but that version is not resolvable from the configured gem source at line #{line_number}"
270
286
  end
287
+ if bundled_with_checksum_mismatch?(path)
288
+ diagnostics.push("#{display_path(path)} BUNDLED WITH #{bundled_with_version(path)} has no matching Bundler checksum")
289
+ end
271
290
  diagnostics
272
291
  end
273
292
 
@@ -316,9 +335,17 @@ module Kettle
316
335
  end
317
336
 
318
337
  def normalization_env
319
- DEFAULT_DISABLED_ENV
320
- .reject { |name, _| allowed_local_path_env_names.include?(name) }
321
- .merge(dynamic_local_path_env)
338
+ disabled_local_path_env
339
+ end
340
+
341
+ # The release graph, not ambient development ENV, decides which local
342
+ # selectors remain active while Bundler normalizes a release lockfile.
343
+ def release_normalization_env
344
+ disabled_local_path_env.merge(release_graph_contract.normalization_environment)
345
+ end
346
+
347
+ def disabled_local_path_env
348
+ DEFAULT_DISABLED_ENV.merge(dynamic_local_path_env)
322
349
  end
323
350
 
324
351
  # Appraisal generation normally uses the release-normalized environment.
@@ -338,7 +365,7 @@ module Kettle
338
365
  ENV.each_with_object({}) do |(key, value), env|
339
366
  next unless key.end_with?("_DEV", "_LOCAL")
340
367
  next unless local_path_env_value?(value)
341
- next if allowed_local_path_env_names.include?(key)
368
+ next if release_graph_contract.selector_env.key?(key)
342
369
 
343
370
  env[key] = "false"
344
371
  end
@@ -360,15 +387,16 @@ module Kettle
360
387
  local_path_env_value?(ENV[name])
361
388
  end
362
389
 
363
- def has_local_path_remote?(path)
364
- !disallowed_local_path_remote_lines(path).empty?
390
+ def has_local_path_remote?(path, strict: false)
391
+ paths = disallowed_local_path_remote_lines(path)
392
+ !paths.empty?
365
393
  end
366
394
 
367
395
  def local_path_remote_lines(path)
368
396
  self.class.local_path_remote_lines_from_source(File.read(path))
369
397
  end
370
398
 
371
- attr_reader :allowed_local_path_roots, :allowed_local_path_env_names
399
+ attr_reader :release_graph_contract
372
400
 
373
401
  def disallowed_local_path_remote_lines(path)
374
402
  local_path_remote_lines(path).reject do |line_number|
@@ -376,25 +404,12 @@ module Kettle
376
404
  end
377
405
  end
378
406
 
379
- def configured_allowed_local_path_roots
380
- ENV.fetch(ALLOWED_LOCAL_PATH_ROOTS_ENV, "").split(File::PATH_SEPARATOR).filter_map do |value|
381
- next if value.strip.empty?
382
-
383
- canonical_path(value)
384
- end.uniq
385
- end
386
-
387
- def configured_allowed_local_path_env_names
388
- ENV.fetch(ALLOWED_LOCAL_PATH_ENVS_ENV, "").split(",").map(&:strip).reject(&:empty?).uniq
389
- end
390
-
391
407
  def local_path_remote_at(path, line_number)
392
408
  File.readlines(path)[line_number - 1].to_s.split("remote:", 2).last.to_s.strip
393
409
  end
394
410
 
395
411
  def allowed_local_path?(path)
396
- remote = canonical_path(path)
397
- allowed_local_path_roots.any? { |root| remote == root || remote.start_with?("#{root}/") }
412
+ release_graph_contract.allowed_path?(path)
398
413
  end
399
414
 
400
415
  def canonical_path(path)
@@ -459,6 +474,33 @@ module Kettle
459
474
  end
460
475
  end
461
476
 
477
+ # A release reset updates both `BUNDLED WITH` and CHECKSUMS. When a
478
+ # nested tool leaked its Bundler into a member lockfile, a later cleanup
479
+ # can otherwise replace only the checksum entry and leave an impossible
480
+ # lockfile behind. Only flag a mismatch when the lockfile already tracks
481
+ # Bundler in CHECKSUMS, preserving older lockfiles that lack that entry.
482
+ def bundled_with_checksum_mismatch?(path)
483
+ version = bundled_with_version(path)
484
+ return false if version.empty?
485
+
486
+ checksums = self.class.checksum_entries_from_source(File.read(path))
487
+ return false unless checksums&.keys&.any? { |name, _checksum_version| name == "bundler" }
488
+
489
+ !checksums.key?(["bundler", version])
490
+ end
491
+
492
+ def bundled_with_version(path)
493
+ lines = File.readlines(path)
494
+ marker = lines.index { |line| line.strip == "BUNDLED WITH" }
495
+ return "" unless marker
496
+
497
+ lines[marker.succ..-1].each do |line|
498
+ version = line.strip
499
+ return version unless version.empty?
500
+ end
501
+ ""
502
+ end
503
+
462
504
  def path_source_gems(path)
463
505
  lockfile_parser(path).specs.each_with_object(Set.new) do |spec, gems|
464
506
  source = spec.source
@@ -888,11 +888,11 @@ module Kettle
888
888
  end
889
889
 
890
890
  def release_lockfile_normalization_needed?(path)
891
- lockfile_reset.normalization_needed?(path)
891
+ lockfile_reset.normalization_needed?(path, strict: true)
892
892
  end
893
893
 
894
894
  def normalize_release_lockfile!(path)
895
- lockfile_reset.reset_lockfile!(path)
895
+ lockfile_reset.reset_lockfile!(path, strict: true)
896
896
  end
897
897
 
898
898
  def release_gemfile_for_lockfile(path)
@@ -900,29 +900,32 @@ module Kettle
900
900
  end
901
901
 
902
902
  def release_lockfile_normalization_env
903
- lockfile_reset.normalization_env
903
+ lockfile_reset.release_normalization_env
904
904
  end
905
905
 
906
906
  # The release process has three distinct lockfile roles:
907
907
  #
908
- # - canonical: tracked project lockfiles, always registry-backed;
908
+ # - canonical: tracked project lockfiles, using the declared release
909
+ # graph (normally registry-only, or contained CI-resident monorepo
910
+ # paths when kettle-family supplied that contract);
909
911
  # - task: a disposable Gemfile.lock copy for build and publication; and
910
912
  # - tool: a tool-owned Gemfile, such as kettle-changelog's release bundle.
911
913
  #
912
- # A family may use local sibling gems to launch this process, but no
913
- # release child may inherit that graph. Git hooks are release children
914
- # too: they commonly load project tooling and otherwise can silently
915
- # rewrite the canonical lockfile with local PATH sources.
914
+ # A family may use local sibling gems to launch this process. Release
915
+ # children receive only the serialized release graph, never the ambient
916
+ # launcher graph. Git hooks are release children too: they commonly load
917
+ # project tooling and otherwise can silently rewrite a lockfile with an
918
+ # undeclared local PATH source.
916
919
  def release_git_hook_environment
917
920
  release_child_environment
918
921
  end
919
922
 
920
923
  def release_lockfile_diagnostics(path)
921
- lockfile_reset.diagnostics(path)
924
+ lockfile_reset.diagnostics(path, strict: true)
922
925
  end
923
926
 
924
927
  def release_lockfile_has_local_path_remote?(path)
925
- lockfile_reset.has_local_path_remote?(path)
928
+ lockfile_reset.has_local_path_remote?(path, strict: true)
926
929
  end
927
930
 
928
931
  def release_lockfile_local_path_remote_lines(path)
@@ -1123,9 +1126,9 @@ module Kettle
1123
1126
  end
1124
1127
 
1125
1128
  # Every release child runs outside the release tool's Bundler context and
1126
- # with local sibling switches disabled. The optional lockfile identifies
1127
- # a disposable task lockfile; omitting it deliberately selects the
1128
- # canonical tracked lockfile for setup, checks, docs, and checksums.
1129
+ # with only the serialized release graph enabled. The optional lockfile
1130
+ # identifies a disposable task lockfile; omitting it deliberately selects
1131
+ # the canonical tracked lockfile for setup, checks, docs, and checksums.
1129
1132
  def release_child_command(command, environment: {}, lockfile: nil)
1130
1133
  command_body = command
1131
1134
  command = +"env"
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Kettle
6
+ module Dev
7
+ # Parsed contract supplied by kettle-family for a single release target.
8
+ # Direct kettle-release calls receive the safe registry-only default.
9
+ class ReleaseGraphContract
10
+ ENV_KEY = "KETTLE_RELEASE_GRAPH_CONTRACT_JSON"
11
+ NAMES = %w[registry_only wave_transition monorepo_ci_local branch_terminal].freeze
12
+
13
+ attr_reader :name, :ci_root, :local_path_roots, :selector_env
14
+
15
+ def self.from_environment(root:)
16
+ raw = (ENV[ENV_KEY] || "").strip
17
+ return new(name: "registry_only", root: root, ci_root: root) if raw.empty?
18
+
19
+ data = JSON.parse(raw)
20
+ new(
21
+ name: data["name"],
22
+ ci_root: data["ci_root"] || root,
23
+ local_path_roots: data["local_path_roots"] || [],
24
+ selector_env: data["selector_env"] || {},
25
+ root: root
26
+ )
27
+ rescue JSON::ParserError, KeyError, TypeError => error
28
+ raise Error, "invalid #{ENV_KEY}: #{error.message}"
29
+ end
30
+
31
+ def initialize(name:, root:, ci_root:, local_path_roots: [], selector_env: {})
32
+ @name = String(name)
33
+ @root = File.realpath(root)
34
+ @ci_root = canonical_path(ci_root)
35
+ @local_path_roots = Array(local_path_roots).map { |path| canonical_path(path) }.uniq.freeze
36
+ @selector_env = selector_env.to_h.map { |key, value| [String(key), String(value)] }.to_h.freeze
37
+ validate!
38
+ freeze
39
+ end
40
+
41
+ def local_paths?
42
+ !local_path_roots.empty?
43
+ end
44
+
45
+ def registry_only?
46
+ name == "registry_only" || name == "branch_terminal"
47
+ end
48
+
49
+ def allowed_path?(path)
50
+ candidate = canonical_path(path)
51
+ local_path_roots.any? { |root| path_within_root?(candidate, root) }
52
+ end
53
+
54
+ def normalization_environment
55
+ selector_env
56
+ end
57
+
58
+ private
59
+
60
+ def validate!
61
+ raise Error, "unknown release graph contract #{name.inspect}" unless NAMES.include?(name)
62
+
63
+ if registry_only? && (local_paths? || !selector_env.empty?)
64
+ raise Error, "#{name} release graph cannot declare local paths or selectors"
65
+ end
66
+ return unless %w[wave_transition monorepo_ci_local].include?(name)
67
+
68
+ raise Error, "#{name} release graph requires local path roots" unless local_paths?
69
+ raise Error, "#{name} release graph requires local path selectors" if selector_env.empty?
70
+
71
+ selector_env.each_value do |value|
72
+ next if allowed_path?(value)
73
+
74
+ raise Error, "#{name} selector #{value.inspect} is outside its declared local path roots"
75
+ end
76
+ return unless name == "monorepo_ci_local"
77
+
78
+ unless path_within_root?(@root, ci_root)
79
+ raise Error, "monorepo_ci_local release root #{@root.inspect} is outside CI root #{ci_root.inspect}"
80
+ end
81
+ local_path_roots.each do |path|
82
+ next if path_within_root?(path, ci_root)
83
+
84
+ raise Error, "monorepo_ci_local path #{path.inspect} is outside CI root #{ci_root.inspect}"
85
+ end
86
+ end
87
+
88
+ def canonical_path(path)
89
+ expanded = File.expand_path(path, @root)
90
+ File.realpath(expanded)
91
+ rescue Errno::ENOENT
92
+ expanded
93
+ end
94
+
95
+ def path_within_root?(path, root)
96
+ path == root || (path[0, root.length] == root && path[root.length] == "/")
97
+ end
98
+ end
99
+ end
100
+ end
@@ -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.0.33"
8
+ VERSION = "3.1.0"
9
9
  end
10
10
  # Current gem version exposed at the traditional constant location.
11
11
  VERSION = Version::VERSION # Traditional Constant Location
data/lib/kettle/dev.rb CHANGED
@@ -49,6 +49,7 @@ module Kettle
49
49
  autoload :OpenCollectiveConfig, "kettle/dev/open_collective_config"
50
50
  autoload :ReleaseNotifier, "kettle/dev/release_notifier"
51
51
  autoload :ReleaseCLI, "kettle/dev/release_cli"
52
+ autoload :ReleaseGraphContract, "kettle/dev/release_graph_contract"
52
53
  autoload :ReleaseSecrets, "kettle/dev/release_secrets"
53
54
  autoload :ReleaseSecretsDoctor, "kettle/dev/release_secrets_doctor"
54
55
  autoload :ResetCLI, "kettle/dev/reset_cli"
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.0.33
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -100,7 +100,7 @@ dependencies:
100
100
  version: '0.1'
101
101
  - - ">="
102
102
  - !ruby/object:Gem::Version
103
- version: 0.1.11
103
+ version: 0.1.12
104
104
  type: :runtime
105
105
  prerelease: false
106
106
  version_requirements: !ruby/object:Gem::Requirement
@@ -110,7 +110,7 @@ dependencies:
110
110
  version: '0.1'
111
111
  - - ">="
112
112
  - !ruby/object:Gem::Version
113
- version: 0.1.11
113
+ version: 0.1.12
114
114
  - !ruby/object:Gem::Dependency
115
115
  name: kettle-test
116
116
  requirement: !ruby/object:Gem::Requirement
@@ -382,6 +382,7 @@ files:
382
382
  - lib/kettle/dev/rakelib/yard.rake
383
383
  - lib/kettle/dev/readme_backers.rb
384
384
  - lib/kettle/dev/release_cli.rb
385
+ - lib/kettle/dev/release_graph_contract.rb
385
386
  - lib/kettle/dev/release_notifier.rb
386
387
  - lib/kettle/dev/release_secrets.rb
387
388
  - lib/kettle/dev/release_secrets_doctor.rb
@@ -400,10 +401,10 @@ licenses:
400
401
  - AGPL-3.0-only
401
402
  metadata:
402
403
  homepage_uri: https://kettle-dev.galtzo.com
403
- source_code_uri: https://github.com/kettle-dev/kettle-dev/tree/v3.0.33
404
- changelog_uri: https://github.com/kettle-dev/kettle-dev/blob/v3.0.33/CHANGELOG.md
404
+ source_code_uri: https://github.com/kettle-dev/kettle-dev/tree/v3.1.0
405
+ changelog_uri: https://github.com/kettle-dev/kettle-dev/blob/v3.1.0/CHANGELOG.md
405
406
  bug_tracker_uri: https://github.com/kettle-dev/kettle-dev/issues
406
- documentation_uri: https://www.rubydoc.info/gems/kettle-dev/3.0.33
407
+ documentation_uri: https://www.rubydoc.info/gems/kettle-dev/3.1.0
407
408
  funding_uri: https://github.com/sponsors/pboling
408
409
  wiki_uri: https://github.com/kettle-dev/kettle-dev/wiki
409
410
  news_uri: https://www.railsbling.com/tags/kettle-dev
metadata.gz.sig CHANGED
@@ -1 +1 @@
1
- �^�m1�V<�`�ț��X��P�G��n�Xۮ��{/\����c4wsݵmQ���T
1
+ �����Vkv�V�b!36\�;��e��'2���7� ����Iށ�>�@N ���1.sHP�I�p�W�%�X^�x)���$y@f�=t ��muN %Ѻ�鑆b�*SH�v޸3�E�1����m;�*�ʲG��S%��2&]G'D�г���fD�P����`��9ܦ�2���ĝ��vZJ�^Z?G�ߛ}Z���)e[�xmB>wq Ǻ���F�~�Z��:�I�5�(b��B���{�F ��Tαm#�� s��(�� 9��!v�'䚁')$���@n+N�������Mڗ�����w�݁<&z�/U7Yž�