kettle-family 1.3.35 → 1.3.37

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: df70ef40b82ccb54155fc57a43a1137d620d3cbd78cd74643d050b9056f3ecdb
4
- data.tar.gz: aff84d0529ac6ef7b78a3248160eb72b4077f51609362c887837b1cd390a9a10
3
+ metadata.gz: b5f2d0bb8696a55925ff46bffa665da140b2f861e47eab151769c9522d160385
4
+ data.tar.gz: e64870163a6fa68862654f72c68ba492382fb8e637d9d1b91c9fb92f4c73dfb5
5
5
  SHA512:
6
- metadata.gz: e3ba12f341362839b45835afda346efa79c49aa886cf5d731980e0d9a2090ffe0297abb752bf72a293135e6ab4fa64c1f132210c882edfef2583c56204fb8355
7
- data.tar.gz: 7cd500810f8b034e00be64dc03574d957c578be29ef1df6d17ca4bf73999c1c47a381d57799e445f1f4c0cabae3622c0ad44152b1152175c1a4b252e0800a034
6
+ metadata.gz: 940c6a65b81be4855325eae21951069520d5ac8ae0e1385e5f31d0060256ce4aed0a81c2baa2e9ec335f010119d0ebdb211d113297c6b7620a5884da637acac5
7
+ data.tar.gz: 896a7fed86b1e22518425858c99720c93ae1740b5b1cc32eb7639212fc691313ae9e619dfb3c7c60186454f32ff4624f02427feda30137e860b6545a40e05666
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,50 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [1.3.37] - 2026-10-30
34
+
35
+ - TAG: [v1.3.37][1.3.37t]
36
+ - COVERAGE: 93.13% -- 7665/8230 lines in 45 files
37
+ - BRANCH COVERAGE: 77.52% -- 3076/3968 branches in 45 files
38
+ - 39.41% documented
39
+
40
+ ### Added
41
+
42
+ - Release preflight now blocks on bootstrap dependency drift: a new `bootstrap dependency drift` phase compares each selected member's committed Gemfile.lock against the newest published version satisfying the member's declared floor for bootstrap-managed gems (`release.drift_check.gems`, default `[nomono]`), failing with a `bundle update <gem>` / family template sweep remediation hint; registry lookup failures skip non-fatally instead of blocking.
43
+
44
+ - New family config release.version_series.independent_members: members that keep their own monotonic version series in a shared-version family. They are skipped by lockstep shared-version raises and exempt from the aggregate GitHub Release same-version invariant, while their built artifacts still attach to the aggregate release.
45
+
46
+ ### Fixed
47
+
48
+ - kettle-family bump now raises shared-changelog members to the family's next shared version when their release is pending, including a misaligned cohort that previously only aligned to the current maximum and reported ok, leaving the unreleased-entries bump gate uncleared with no documented command able to clear it (#17).
49
+
50
+ - Independently versioned members (release.version_series.independent_members) can no longer be captured by the lockstep shared-version raise: they bump on their own series, are never force-marked pending by the shared cohort, their higher own-series version no longer drags the cohort line, and the aggregate GitHub Release no longer fails its same-version check on them (#18).
51
+
52
+ ### Security
53
+
54
+ - Generated resume commands no longer embed secret env values: secret-shaped keys (at minimum `KETTLE_RELEASE_GEM_SIGNING_PASSPHRASE`) are redacted to the shell-safe placeholder `<redacted-reauthorize>`; re-authorize the secret through the release secrets provider (1Password or the `KETTLE_RELEASE_SECRETS_BROKER` cache), or paste the real value over the placeholder, before resuming.
55
+
56
+ ## [1.3.36] - 2026-10-28
57
+
58
+ - TAG: [v1.3.36][1.3.36t]
59
+ - COVERAGE: 93.12% -- 7601/8163 lines in 45 files
60
+ - BRANCH COVERAGE: 77.34% -- 3041/3932 branches in 45 files
61
+ - 38.89% documented
62
+
63
+ ### Changed
64
+
65
+ - [kc] template-family-resume-hints: Template failures now produce one combined retry hint for failed and pending members, preserving member@branch selectors for branch-stack templates.
66
+
67
+ - [kc] kettle-jem/prepare: updated 7 project files:
68
+ - configuration (1)
69
+ - dependencies (6)
70
+
71
+ - [kc] kettle-jem/template: updated 5 project files:
72
+ - code and tests (1)
73
+ - documentation (1)
74
+ - other (2)
75
+ - workflows (1)
76
+
33
77
  ## [1.3.35] - 2026-10-26
34
78
 
35
79
  - TAG: [v1.3.35][1.3.35t]
@@ -3299,7 +3343,11 @@ Please file a bug if you notice a violation of semantic versioning.
3299
3343
  - Fixed CI load failures on engines without compatible `pty` support by falling back to Open3 for interactive release commands.
3300
3344
  - Fixed Ruby 3.2 version-bump support by loading Prism lazily and wiring the Prism gem only for MRI versions that need it.
3301
3345
 
3302
- [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.3.35...HEAD
3346
+ [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.3.37...HEAD
3347
+ [1.3.37]: https://github.com/kettle-dev/kettle-family/compare/v1.3.36...v1.3.37
3348
+ [1.3.37t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.37
3349
+ [1.3.36]: https://github.com/kettle-dev/kettle-family/compare/v1.3.35...v1.3.36
3350
+ [1.3.36t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.36
3303
3351
  [1.3.35]: https://github.com/kettle-dev/kettle-family/compare/v1.3.34...v1.3.35
3304
3352
  [1.3.35t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.35
3305
3353
  [1.3.34]: https://github.com/kettle-dev/kettle-family/compare/v1.3.33...v1.3.34
data/README.md CHANGED
@@ -64,12 +64,12 @@ configuration, create their own GitHub Release through `kettle-release`.
64
64
 
65
65
  ### Compatibility
66
66
 
67
- Compatible with MRI Ruby 4.0.0+.
67
+ The gem's declared runtime compatibility floor is MRI Ruby 4.0.0+, with concordant releases of JRuby and TruffleRuby.
68
68
  CI workflows and Appraisals are generated for MRI Ruby 4.0.0+.
69
69
  The current MRI CI lane runs on Linux and macOS.
70
- This test floor is configured by `ruby.test_minimum` in `.kettle-jem.yml` and
71
- may be higher than the gem's runtime compatibility floor when legacy Rubies are
72
- not practical for the current toolchain.
70
+ The test floor is configured by `ruby.test_minimum` in `.kettle-jem.yml`.
71
+ When it is higher than the runtime floor, older runtime-compatible Rubies are
72
+ not covered by the generated CI or Appraisal matrix.
73
73
 
74
74
  <a href="https://github.com/kettle-dev"><img alt="kettle-dev Logo by Aboling0, CC BY-SA 4.0" src="https://logos.galtzo.com/assets/images/kettle-dev/avatar-128px.svg" width="14%" align="right"/></a>
75
75
 
@@ -1191,7 +1191,7 @@ Thanks for RTFM. ☺️
1191
1191
  [📌gitmoji]: https://gitmoji.dev
1192
1192
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
1193
1193
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
1194
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-8.121-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1194
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-8.230-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1195
1195
  [🔐security]: https://github.com/kettle-dev/kettle-family/blob/main/SECURITY.md
1196
1196
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
1197
1197
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -1475,19 +1475,29 @@ module Kettle
1475
1475
  def shared_bump_member_names(config:, members:)
1476
1476
  return [] unless config.shared_changelog?
1477
1477
 
1478
- members.reject { |member| config.member_local_changelog?(member) }.map(&:name)
1478
+ members
1479
+ .reject { |member| config.member_local_changelog?(member) }
1480
+ .reject { |member| config.independent_version_series_member?(member) }
1481
+ .map(&:name)
1479
1482
  end
1480
1483
 
1481
1484
  def shared_bump_target_version(config:, members:, options:, release_state_results: nil)
1482
1485
  return unless config.shared_changelog?
1483
1486
  return unless Kettle::Dev::VersionBump::BUMP_TYPES.include?(options[:target_version].to_s)
1484
1487
 
1488
+ # The lockstep raise cohort excludes members carrying their own
1489
+ # changelog AND independently versioned members: an independent
1490
+ # series must never be captured by (or drag) the shared version
1491
+ # line, in either direction.
1485
1492
  shared_members = members.reject { |member| config.member_local_changelog?(member) }
1493
+ .reject { |member| config.independent_version_series_member?(member) }
1486
1494
  return if shared_members.empty?
1487
1495
 
1488
1496
  versions = shared_members.map { |member| Gem::Version.new(member.version.to_s) }.uniq
1489
1497
  current = versions.max
1490
- return current.to_s if versions.length > 1
1498
+ # Without release-state results (explicit member selection) a
1499
+ # misaligned cohort still aligns to its maximum, as before.
1500
+ return current.to_s if versions.length > 1 && release_state_results.nil?
1491
1501
 
1492
1502
  released_versions = Array(release_state_results).filter_map do |result|
1493
1503
  next unless shared_members.any? { |member| member.name == result.member_name }
@@ -1502,8 +1512,15 @@ module Kettle
1502
1512
  rescue ArgumentError
1503
1513
  nil
1504
1514
  end
1515
+ # Mid-prep (the cohort line already moved past the last release):
1516
+ # laggards align up to the in-flight version and nothing increments.
1505
1517
  return current.to_s if released_versions.any? && current > released_versions.max
1506
1518
 
1519
+ # Otherwise the cohort owes a release: every shared member is raised
1520
+ # to the family's NEXT shared version — including a misaligned
1521
+ # cohort, which previously aligned to the current maximum and then
1522
+ # reported ok, leaving the unreleased-entries bump gate uncleared
1523
+ # with no documented command able to clear it (#17).
1507
1524
  Kettle::Dev::VersionBump.resolve_target_version(options[:target_version].to_s, current.to_s)
1508
1525
  end
1509
1526
 
@@ -308,6 +308,17 @@ module Kettle
308
308
  end.compact
309
309
  end
310
310
 
311
+ # Bootstrap-managed gems whose committed member locks must not lag the
312
+ # newest published version satisfying the member's declared requirement
313
+ # (release.drift_check.gems; default: nomono, the bootstrap gem whose
314
+ # lock/registry drift killed the SM-ruby wave on 2026-10-30). An empty
315
+ # list disables the drift preflight phase entirely.
316
+ def release_drift_check_gems
317
+ configured = fetch_path("release", "drift_check", "gems")
318
+ gems = configured.nil? ? [NomonoBootstrap::GEM_NAME] : Array(configured)
319
+ gems.map(&:to_s).map(&:strip).reject(&:empty?).uniq
320
+ end
321
+
311
322
  def release_required_remotes
312
323
  fetch_path("release", "required_remotes")
313
324
  end
@@ -405,6 +416,24 @@ module Kettle
405
416
  fetch_path("release", "aggregate_validation", "enabled") == true
406
417
  end
407
418
 
419
+ # Members that keep their own monotonic version series even in a
420
+ # shared-version family (release.version_series.independent_members;
421
+ # default empty). An independent-series member is skipped by lockstep
422
+ # shared-version raises — its bumps stay on its own series — and is
423
+ # exempt from the aggregate GitHub Release same-version invariant,
424
+ # while its built artifacts still attach to the aggregate release.
425
+ def release_version_series_independent_members
426
+ Array(fetch_path("release", "version_series", "independent_members"))
427
+ .map(&:to_s)
428
+ .map(&:strip)
429
+ .reject(&:empty?)
430
+ .uniq
431
+ end
432
+
433
+ def independent_version_series_member?(member)
434
+ release_version_series_independent_members.include?(member.name.to_s)
435
+ end
436
+
408
437
  def release_aggregate_validation_excluded_members
409
438
  Array(fetch_path("release", "aggregate_validation", "exclude_members"))
410
439
  .map(&:to_s)
@@ -113,7 +113,7 @@ module Kettle
113
113
 
114
114
  def check_member(member, branch: nil, event_handler: nil)
115
115
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
116
- command = release_state_command
116
+ command = release_state_command(member)
117
117
  emit_event(event_handler, member: member, branch: branch, action: "member_start", status: "running", command: command)
118
118
  emit_event(event_handler, member: member, branch: branch, action: "changelog_command", status: "running", command: command)
119
119
  stdout, stderr, status = capture_release_state(
@@ -142,15 +142,16 @@ module Kettle
142
142
  emit_event(event_handler, member: member, branch: branch, action: "member_complete", status: success ? "ok" : "failed", elapsed_seconds: elapsed, status_code: status.exitstatus)
143
143
  result
144
144
  rescue JSON::ParserError => error
145
- result = result(member: member, command: command || release_state_command, stdout: stdout, stderr: stderr, status: 1, elapsed: elapsed || 0.0, success: false, state: {}, reason: "invalid release-state JSON: #{error.message}", branch: branch)
145
+ result = result(member: member, command: command || release_state_command(member), stdout: stdout, stderr: stderr, status: 1, elapsed: elapsed || 0.0, success: false, state: {}, reason: "invalid release-state JSON: #{error.message}", branch: branch)
146
146
  emit_event(event_handler, member: member, branch: branch, action: "member_complete", status: "failed", elapsed_seconds: elapsed || 0.0, status_code: 1, reason: result.reason)
147
147
  result
148
148
  end
149
149
 
150
- def release_state_command
151
- [RbConfig.ruby, kettle_changelog_executable, "--release-state", "--json"]
150
+ def release_state_command(member = nil)
151
+ version_args = member&.version ? ["--version", member.version.to_s] : []
152
+ [RbConfig.ruby, kettle_changelog_executable, *version_args, "--release-state", "--json"]
152
153
  rescue Gem::Exception
153
- [RbConfig.ruby, "-S", "kettle-changelog", "--release-state", "--json"]
154
+ [RbConfig.ruby, "-S", "kettle-changelog", *version_args, "--release-state", "--json"]
154
155
  end
155
156
 
156
157
  def kettle_changelog_executable
@@ -176,7 +177,7 @@ module Kettle
176
177
 
177
178
  def check_shared_changelog_member(member, branch: nil, event_handler: nil)
178
179
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
179
- command = release_state_command
180
+ command = release_state_command(member)
180
181
  emit_event(event_handler, member: member, branch: branch, action: "member_start", status: "running", command: command)
181
182
  emit_event(event_handler, member: member, branch: branch, action: "changelog_command", status: "running", command: command)
182
183
  stdout, stderr, status = capture_release_state(
@@ -216,13 +217,13 @@ module Kettle
216
217
  emit_event(event_handler, member: member, branch: branch, action: "member_complete", status: success ? "ok" : "failed", elapsed_seconds: elapsed, status_code: status.exitstatus)
217
218
  result
218
219
  rescue JSON::ParserError => error
219
- result = result(member: member, command: command || release_state_command, stdout: stdout, stderr: stderr, status: 1, elapsed: elapsed || 0.0, success: false, state: {}, reason: "invalid release-state JSON: #{error.message}", branch: branch)
220
+ result = result(member: member, command: command || release_state_command(member), stdout: stdout, stderr: stderr, status: 1, elapsed: elapsed || 0.0, success: false, state: {}, reason: "invalid release-state JSON: #{error.message}", branch: branch)
220
221
  emit_event(event_handler, member: member, branch: branch, action: "member_complete", status: "failed", elapsed_seconds: elapsed || 0.0, status_code: 1, reason: result.reason)
221
222
  result
222
223
  rescue Error => error
223
224
  result = result(
224
225
  member: member,
225
- command: command || release_state_command,
226
+ command: command || release_state_command(member),
226
227
  stdout: "",
227
228
  stderr: error.message,
228
229
  status: 1,
@@ -804,8 +805,6 @@ module Kettle
804
805
  return unless member_config
805
806
 
806
807
  current_result = check_member(member, event_handler: event_handler)
807
- return [current_result] unless current_result.ok?
808
-
809
808
  [current_result, *self.class.new(config: member_config, members: [member]).results(event_handler: event_handler)]
810
809
  end
811
810
 
@@ -831,8 +830,9 @@ module Kettle
831
830
  end
832
831
 
833
832
  # Version-locked siblings move in lockstep. Members not declared
834
- # independently releasable (release.aggregate_validation.exclude_members)
835
- # share the family version line, so:
833
+ # independently releasable (release.aggregate_validation.exclude_members
834
+ # or release.version_series.independent_members) share the family
835
+ # version line, so:
836
836
  # 1. when any of them needs a version bump, all of them do — including
837
837
  # members without unreleased changelog entries of their own and
838
838
  # brand-new members that have never been released (empty
@@ -840,12 +840,13 @@ module Kettle
840
840
  # 2. a member whose checkout version sits behind the cohort's version
841
841
  # line (e.g. a cohort that already bumped while it stayed put) needs
842
842
  # a catch-up bump on its own.
843
- # Independently releasable members keep per-member bump semantics: they
844
- # bump only when their own unreleased entries require it. Members whose
845
- # checkout version already moved past their last release are mid-prep
846
- # and are never double-bumped.
843
+ # Independently versioned members keep per-member bump semantics: they
844
+ # bump only when their own unreleased entries require it, and their
845
+ # (possibly higher) own-series version never drags the cohort line.
846
+ # Members whose checkout version already moved past their last release
847
+ # are mid-prep and are never double-bumped.
847
848
  def normalize_lockstep_version_bump(results)
848
- excluded = Array(config&.release_aggregate_validation_excluded_members).map(&:to_s)
849
+ excluded = independent_bump_member_names
849
850
  return results if excluded.empty?
850
851
 
851
852
  aggregate = results.select do |result|
@@ -895,6 +896,17 @@ module Kettle
895
896
  nil
896
897
  end
897
898
 
899
+ # Members exempt from lockstep bump semantics: aggregate-validation
900
+ # exclusions plus independently versioned members. Both keep per-member
901
+ # bump behavior; neither may be force-marked pending by the cohort, and
902
+ # an independent-series version never participates in the cohort line.
903
+ def independent_bump_member_names
904
+ Array(config&.release_aggregate_validation_excluded_members)
905
+ .concat(Array(config&.release_version_series_independent_members))
906
+ .map(&:to_s)
907
+ .uniq
908
+ end
909
+
898
910
  def normalize_shared_version_bump(results)
899
911
  shared_results = results.select { |result| result.ok? && shared_changelog_member_result?(result) }
900
912
  return results unless shared_results.any? { |result| result.state["bump_release_pending"] == true }
@@ -920,7 +932,11 @@ module Kettle
920
932
 
921
933
  def shared_changelog_member_result?(result)
922
934
  member = members.find { |candidate| candidate.name == result.member_name }
923
- member && !member_local_changelog?(member)
935
+ member && !member_local_changelog?(member) && !independent_version_series_member?(member)
936
+ end
937
+
938
+ def independent_version_series_member?(member)
939
+ config&.independent_version_series_member?(member) || false
924
940
  end
925
941
 
926
942
  def git_root
@@ -125,7 +125,7 @@ module Kettle
125
125
  lines << " #{result_state(result)} #{result_identity(result)} #{result.phase} #{result.reason || ""}".rstrip
126
126
  append_result_stdout(lines, result)
127
127
  append_indented_output(lines, result.stderr) if !result.ok? && !result.stderr.to_s.empty?
128
- resume_hint = resume_hint_for(result)
128
+ resume_hint = resume_hint_for(result) unless command == "template"
129
129
  lines << " resume: #{resume_hint}" if !result.ok? && resume_hint
130
130
  end
131
131
  append_template_summary(lines) if command == "template"
@@ -764,11 +764,10 @@ module Kettle
764
764
  failed ? [resume_hint_for(failed)] : []
765
765
  end
766
766
 
767
- # Mirrors release_resume_hints: one hint per failure, plus a single set-based
768
- # hint naming every member that never ran. --only selects by name rather
769
- # than position, so it resumes exactly the unfinished members regardless of
770
- # how dispatch ordered them, and the wave grouping is re-derived from the
771
- # selected subset.
767
+ # Test gets one hint per failure plus a set-based hint for members that never
768
+ # ran. --only selects by name rather than position, so it resumes exactly the
769
+ # unfinished members regardless of how dispatch ordered them. Template uses
770
+ # one combined hint because it is idempotent and family-lockstep.
772
771
  #
773
772
  # A family-root failure takes precedence and suppresses the set-based hint:
774
773
  # that phase aborts the entire run before member dispatch, so every member
@@ -776,6 +775,7 @@ module Kettle
776
775
  def non_positional_resume_hints
777
776
  root_failure = results.find { |result| !result.ok? && family_root_phase?(result.phase) }
778
777
  return [resume_hint_for(root_failure)] if root_failure
778
+ return template_resume_hints if command == "template"
779
779
 
780
780
  failed_hints = visible_results.reject(&:ok?).filter_map { |result| resume_hint_for(result) }.uniq
781
781
  pending_names = summary_pending.map { |entry| entry.fetch("member") }
@@ -784,6 +784,15 @@ module Kettle
784
784
  [*failed_hints, "kettle-family #{command} --only #{pending_names.join(",")}"]
785
785
  end
786
786
 
787
+ def template_resume_hints
788
+ member_names = visible_results.reject(&:ok?).map { |result| result_identity(result) }
789
+ member_names.concat(summary_pending.map { |entry| entry.fetch("member") })
790
+ member_names = member_names.compact.uniq.sort
791
+ return ["kettle-family template"] if member_names.empty?
792
+
793
+ ["kettle-family template --only #{member_names.join(",")}"]
794
+ end
795
+
787
796
  def release_resume_hints
788
797
  failed_hints = visible_results.reject(&:ok?).filter_map { |result| resume_hint_for(result) }.uniq
789
798
  pending_names = summary_pending.map { |entry| entry.fetch("member") }
@@ -5,7 +5,7 @@ module Kettle
5
5
  # Version namespace for this gem.
6
6
  module Version
7
7
  # Current gem version.
8
- VERSION = "1.3.35"
8
+ VERSION = "1.3.37"
9
9
  end
10
10
  # Current gem version exposed at the traditional constant location.
11
11
  VERSION = Version::VERSION # Traditional Constant Location
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "kettle/dev"
4
+ require "ast/crispr/ruby/prism"
4
5
 
5
6
  module Kettle
6
7
  module Family
@@ -74,12 +75,20 @@ module Kettle
74
75
  end
75
76
 
76
77
  def collect_edits(member, member_target_version)
77
- version_edits = with_dev_errors do
78
- Kettle::Dev::VersionBump.new(
79
- root: member.root,
80
- current_version: member.version,
81
- target_version: member_target_version
82
- ).edits
78
+ begin
79
+ version_edits = with_dev_errors do
80
+ Kettle::Dev::VersionBump.new(
81
+ root: member.root,
82
+ current_version: member.version,
83
+ target_version: member_target_version
84
+ ).edits
85
+ end
86
+ rescue Error => error
87
+ bump = Kettle::Dev::VersionBump.new(root: member.root, current_version: member.version, target_version: member_target_version)
88
+ computed_version_edits = prism_computed_version_edits(member, member_target_version)
89
+ raise error if computed_version_edits.empty?
90
+
91
+ version_edits = computed_version_edits + bump.send(:gemspec_version_edits)
83
92
  end
84
93
  version_edits = version_edits.map do |edit|
85
94
  if same_file?(edit.fetch(:path), member.gemspec_path)
@@ -91,6 +100,33 @@ module Kettle
91
100
  version_edits + gemspec_dependency_edits(member)
92
101
  end
93
102
 
103
+ def prism_computed_version_edits(member, target_version)
104
+ version = Gem::Version.new(target_version)
105
+ replacements = {major: version.segments[0] || 0, minor: version.segments[1] || 0, patch: version.segments[2] || 0}
106
+ Kettle::Dev::Versioning.version_file_candidates(member.root).flat_map do |path|
107
+ source = File.read(path)
108
+ analysis = Ast::Crispr::Ruby::Prism.document_context(content: source, source_label: path).ast
109
+ root = analysis.parse_result.value
110
+ nodes = Kettle::Dev::VersionBump.each_node(root).to_a
111
+ version_constant = nodes.find do |node|
112
+ node.is_a?(Prism::ConstantWriteNode) && node.name == :VERSION && node.value.is_a?(Prism::CallNode) && node.value.name == :to_s
113
+ end
114
+ next [] unless version_constant
115
+
116
+ replacements.filter_map do |method_name, replacement_value|
117
+ method = nodes.find { |node| node.is_a?(Prism::DefNode) && node.name == method_name }
118
+ literal = method&.body&.body&.last
119
+ next unless literal.is_a?(Prism::IntegerNode) || literal.is_a?(Prism::StringNode)
120
+
121
+ next if literal.is_a?(Prism::IntegerNode) && literal.value == replacement_value
122
+ next if literal.is_a?(Prism::StringNode) && literal.unescaped == replacement_value.to_s
123
+
124
+ replacement = literal.is_a?(Prism::StringNode) ? Kettle::Dev::VersionBump.quote_like(literal.location.slice, replacement_value.to_s) : replacement_value.to_s
125
+ Kettle::Dev::VersionBump.file_edit(path, source, literal.location.start_offset, literal.location.end_offset, replacement)
126
+ end
127
+ end
128
+ end
129
+
94
130
  def same_file?(left, right)
95
131
  right && File.identical?(left, right)
96
132
  rescue SystemCallError
@@ -108,6 +108,16 @@ module Kettle
108
108
  REGISTRY_WAIT_ATTEMPTS = 15
109
109
  REGISTRY_WAIT_INTERVAL_SECONDS = 15
110
110
  TEMPLATE_GEMSPEC_RETRY_DELAY_SECONDS = 0.25
111
+ # Placeholder substituted for secret-valued env keys in generated resume
112
+ # commands (see aggregate_github_release_resume_command).
113
+ RESUME_COMMAND_REDACTED_VALUE = "<redacted-reauthorize>"
114
+ # Env key names whose values must never appear in printed commands.
115
+ # Matches by name shape, not a fixed list, so future secrets plumbing
116
+ # (new passphrase/OTP/token transports) is scrubbed by default. The
117
+ # excluded suffixes are routing metadata an operator needs to
118
+ # re-authorize (source/provider/broker/field NAME), never a secret.
119
+ RESUME_COMMAND_SECRET_ENV_KEY_RE = /PASSPHRASE|PASSWORD|SECRET|TOKEN|OTP|CREDENTIAL/i
120
+ RESUME_COMMAND_SECRET_ENV_KEY_EXCLUDE_RE = /(?:_SOURCE|_PROVIDER|_BROKER|_FIELD)\z/i
111
121
 
112
122
  def initialize(command:, config:, members:, family_members: nil, execute: false, accept: true, commit: true, allow_dirty: false, autostash: true, template_cleanup: true, publish: false, push: false, tag: false, start_step: nil, skip_steps: nil, fast_recovery: nil, fast_recovery_members: nil, skip_ci: false, skip_changelog: false, skip_appraisals: false, local_ci: false, continue_ci_failures: false, ci_workflows: nil, skip_bundle_audit: false, skip_remotes: nil, required_remotes: nil, auto_dependency_floors: nil, validate_ci_bundles: nil, gha_sha_pins_upgrade: "patch", gha_sha_pins_check: false, gha_sha_pins_ttl_days: nil, env_overrides: {}, debug: false, verbose: false, gem_signing_password: nil, secrets_provider: nil, jobs: nil, progress_io: nil, reset_target: nil, bup_args: [], bex_args: [], start_member: nil, start_branch: nil, template_branch_selection: {}, release_state_results: nil, event_tape: nil, mise_adapter: nil, **options)
113
123
  @command = command
@@ -2024,12 +2034,13 @@ module Kettle
2024
2034
  def release_preflight_phases
2025
2035
  maintenance_phase = {label: "pre-release maintenance", method: :release_preflight_maintenance_results} unless config.release_preflight_commands.empty?
2026
2036
  bump_phase = {label: "release version bump", method: :release_preflight_version_bump_results} unless release_state_results.empty?
2037
+ drift_phase = {label: "bootstrap dependency drift", method: :release_preflight_bootstrap_drift_results} unless config.release_drift_check_gems.empty?
2027
2038
  dirty_phase = {label: "branch checkout readiness", method: :release_preflight_branch_checkout_dirty_results}
2028
2039
  signing_phase = {label: "gem signing password", method: :release_preflight_signing_password_results}
2029
2040
  secrets_phase = release_secrets_authorization_required? ? {label: "secrets provider authorization", method: :release_preflight_secrets_authorization_results} : nil
2030
2041
  gha_sha_pins_phase = release_gha_sha_pins_review_required? ? {label: "GitHub Actions SHA pins", method: :release_preflight_gha_sha_pins_results} : nil
2031
2042
 
2032
- [maintenance_phase, bump_phase, secrets_phase, gha_sha_pins_phase, dirty_phase, signing_phase].compact
2043
+ [maintenance_phase, bump_phase, drift_phase, secrets_phase, gha_sha_pins_phase, dirty_phase, signing_phase].compact
2033
2044
  end
2034
2045
 
2035
2046
  # Family-declared pre-release maintenance hooks (release.preflight_commands
@@ -2075,6 +2086,108 @@ module Kettle
2075
2086
  results
2076
2087
  end
2077
2088
 
2089
+ # Bootstrap-managed gems (config release.drift_check.gems, default:
2090
+ # nomono) are activated by a member's own Gemfile bootstrap, while CI
2091
+ # resolves fresh bundles from the registry. When a newer published
2092
+ # version satisfies the member's declared floor but the committed lock
2093
+ # pins an older one, CI activates the newer version while the member
2094
+ # bootstrap activates the locked older one, and the wave dies mid-job
2095
+ # with a Gem::LoadError deep in a cold-start (observed 2026-10-30:
2096
+ # nomono locked 1.1.16 vs published 1.1.17 satisfying a ">= 1.1.16"
2097
+ # floor, run 40998206316). Nothing compared locked vs
2098
+ # published-satisfying versions pre-release, so this phase blocks early
2099
+ # with the remediation instead of letting CI discover the drift.
2100
+ def release_preflight_bootstrap_drift_results
2101
+ config.release_drift_check_gems.flat_map do |gem|
2102
+ members.filter_map { |member| bootstrap_dependency_drift_result(member, gem) }
2103
+ end
2104
+ end
2105
+
2106
+ def bootstrap_dependency_drift_result(member, gem)
2107
+ locked = locked_bootstrap_gem_version(member, gem)
2108
+ return nil if locked.nil?
2109
+
2110
+ requirement = declared_bootstrap_gem_requirement(member, gem)
2111
+ newest = newest_published_satisfying_version(gem, requirement)
2112
+ if newest.nil?
2113
+ # A registry outage must never become a false release block: treat
2114
+ # "could not consult the registry" as a skip with a diagnostic.
2115
+ warn "[kettle-family] bootstrap dependency drift check skipped for #{gem}: registry could not be consulted"
2116
+ return nil
2117
+ end
2118
+ return nil if locked >= newest
2119
+
2120
+ release_preflight_error_result(
2121
+ "bootstrap_dependency_drift",
2122
+ "Release blocked: bootstrap dependency drift: #{member.name} locks #{gem} #{locked} but #{newest} satisfies its floor; " \
2123
+ "run the family template sweep or `bundle update #{gem}` in the member before releasing.",
2124
+ member: member
2125
+ )
2126
+ end
2127
+
2128
+ # The version a member's committed lock pins for the gem, from the
2129
+ # registry-sourced GEM section only: a PATH or GIT pin is local-only
2130
+ # state that has no published counterpart to drift against.
2131
+ def locked_bootstrap_gem_version(member, gem)
2132
+ lockfile_path = File.join(member.root, "Gemfile.lock")
2133
+ return nil unless File.file?(lockfile_path)
2134
+
2135
+ section = nil
2136
+ File.foreach(lockfile_path) do |line|
2137
+ section = line.strip unless line.match?(/\A\s/)
2138
+ next unless section == "GEM"
2139
+
2140
+ match = line.match(/\A {4}#{Regexp.escape(gem)} \(([^)]+)\)\s*\z/)
2141
+ next unless match
2142
+
2143
+ begin
2144
+ return Gem::Version.new(match[1])
2145
+ rescue ArgumentError
2146
+ return nil
2147
+ end
2148
+ end
2149
+ nil
2150
+ end
2151
+
2152
+ # The requirement the member's Gemfile declared for the gem, read from
2153
+ # the lock's DEPENDENCIES section (Bundler records the DSL requirement
2154
+ # there verbatim). A bare declaration means "any version".
2155
+ def declared_bootstrap_gem_requirement(member, gem)
2156
+ lockfile_path = File.join(member.root, "Gemfile.lock")
2157
+ return Gem::Requirement.default unless File.file?(lockfile_path)
2158
+
2159
+ section = nil
2160
+ File.foreach(lockfile_path) do |line|
2161
+ section = line.strip unless line.match?(/\A\s/)
2162
+ next unless section == "DEPENDENCIES"
2163
+
2164
+ match = line.match(/\A {2}#{Regexp.escape(gem)}(?: \((.+)\))?\s*\z/)
2165
+ next unless match
2166
+
2167
+ constraints = (match[1] || "").split(",").map(&:strip).reject(&:empty?)
2168
+ return Gem::Requirement.new(constraints) unless constraints.empty?
2169
+
2170
+ return Gem::Requirement.default
2171
+ end
2172
+ Gem::Requirement.default
2173
+ end
2174
+
2175
+ # Newest published (non-prerelease) version satisfying the requirement,
2176
+ # via the ETag-cached registry lookup shared with the rest of the
2177
+ # toolchain; nil when the registry could not be consulted.
2178
+ def newest_published_satisfying_version(gem, requirement)
2179
+ numbers = Kettle::Dev::RubyGemsVersions.published_version_numbers(gem)
2180
+ return nil if numbers.nil?
2181
+
2182
+ numbers.filter_map do |number|
2183
+ Gem::Version.new(number)
2184
+ rescue ArgumentError
2185
+ nil
2186
+ end.select do |version|
2187
+ !version.prerelease? && requirement.satisfied_by?(version)
2188
+ end.max
2189
+ end
2190
+
2078
2191
  def release_gha_sha_pins_review_required?
2079
2192
  execute && kettle_release_command?(raw_release_command) && !release_gha_sha_pins_offline_requested?
2080
2193
  end
@@ -2119,11 +2232,14 @@ module Kettle
2119
2232
  @secrets_provider.is_a?(Secrets::OnePassword)
2120
2233
  end
2121
2234
 
2122
- def release_preflight_error_result(phase, message)
2235
+ def release_preflight_error_result(phase, message, member: nil)
2123
2236
  # A preflight failure blocks the selected release invocation. Attribute
2124
2237
  # it to that invocation's first member so generated resume commands do
2125
- # not target the synthetic family name or an unselected sibling.
2126
- member = members.first || family_member
2238
+ # not target the synthetic family name or an unselected sibling. Phases
2239
+ # that identify a specific offending member (e.g. bootstrap dependency
2240
+ # drift) override that attribution so the resume hint targets the
2241
+ # member that needs the fix.
2242
+ member ||= members.first || family_member
2127
2243
  CommandResult.new(
2128
2244
  member_name: member.name,
2129
2245
  phase: phase,
@@ -3789,6 +3905,7 @@ module Kettle
3789
3905
  def append_kettle_release_args(command, member: nil)
3790
3906
  command = replace_kettle_release_provider(command) if @release_secrets_broker
3791
3907
  args = []
3908
+ args << "--version=#{member.version}" if member&.version && !command_includes_arg?(command, "--version")
3792
3909
  effective_start_step = release_start_step_for(member)
3793
3910
  command, configured_skip_steps = extract_kettle_release_skip_steps(command)
3794
3911
  effective_skip_steps = (configured_skip_steps + release_skip_steps_for(member).split(",")).uniq.join(",")
@@ -3888,6 +4005,16 @@ module Kettle
3888
4005
  aggregate_validation_members.include?(member)
3889
4006
  end
3890
4007
 
4008
+ # An independently versioned member (release.version_series.
4009
+ # independent_members) keeps its own monotonic series: it is exempt
4010
+ # from lockstep raises and from the aggregate same-version invariant,
4011
+ # but remains part of the aggregate release for artifact attachment.
4012
+ def independent_version_series_member?(member)
4013
+ return false unless member
4014
+
4015
+ config.independent_version_series_member?(member)
4016
+ end
4017
+
3891
4018
  def aggregate_validation_member_name?(member)
3892
4019
  member && member.name == (@aggregate_validation_member_name || aggregate_validation_members.first&.name)
3893
4020
  end
@@ -4108,12 +4235,19 @@ module Kettle
4108
4235
 
4109
4236
  # simplecov:disable Covered by monorepo release integration, not sibling-repository suite.
4110
4237
  def aggregate_monorepo_github_release(members)
4111
- version = members.map { |member| member.version.to_s }.uniq
4238
+ # The same-version invariant describes the shared-version cohort.
4239
+ # Independently versioned members (release.version_series.
4240
+ # independent_members) can never satisfy it by design, so they are
4241
+ # exempt from the uniformity check while their artifacts still
4242
+ # attach — at their own series version, not the cohort's.
4243
+ shared_members = members.reject { |member| independent_version_series_member?(member) }
4244
+ version = shared_members.map { |member| member.version.to_s }.uniq
4112
4245
  return aggregate_release_result("all selected members must have the same version") unless version.length == 1
4113
4246
 
4114
4247
  assets = members.flat_map do |member|
4115
- gem_path = Paths.glob(member.root, "pkg", "*.gem").select { |path| File.basename(path).end_with?("-#{version.first}.gem") }
4116
- checksum_paths = %w[sha256 sha512].map { |algorithm| File.join(member.root, "checksums", "#{member.name}-#{version.first}.gem.#{algorithm}") }
4248
+ asset_version = independent_version_series_member?(member) ? member.version.to_s : version.first
4249
+ gem_path = Paths.glob(member.root, "pkg", "*.gem").select { |path| File.basename(path).end_with?("-#{asset_version}.gem") }
4250
+ checksum_paths = %w[sha256 sha512].map { |algorithm| File.join(member.root, "checksums", "#{member.name}-#{asset_version}.gem.#{algorithm}") }
4117
4251
  gem_path + checksum_paths.select { |path| File.file?(path) }
4118
4252
  end
4119
4253
  command = ["bundle", "exec", "kettle-gh-release", "--allow-unpublished", "--events", "--release-version", version.first]
@@ -4158,11 +4292,26 @@ module Kettle
4158
4292
 
4159
4293
  def aggregate_github_release_resume_command(env, command)
4160
4294
  assignments = env.sort_by { |key, _value| key }.filter_map do |key, value|
4161
- "#{key}=#{Shellwords.escape(value.to_s)}" unless value.nil?
4295
+ next if value.nil?
4296
+
4297
+ # Resume commands land in terminal output, run summaries, and
4298
+ # transcripts. Env built from the broker-cached secrets used to
4299
+ # embed KETTLE_RELEASE_GEM_SIGNING_PASSPHRASE in plaintext here
4300
+ # (2026-10-30 incident). Redact secret-valued keys instead: the
4301
+ # printed command keeps its shape, and the operator re-authorizes
4302
+ # the secret through the release secrets provider (1Password, or
4303
+ # the KETTLE_RELEASE_SECRETS_BROKER cache) — or pastes the real
4304
+ # passphrase in place of the placeholder — before resuming.
4305
+ rendered_value = resume_command_secret_env_key?(key) ? Shellwords.escape(RESUME_COMMAND_REDACTED_VALUE) : Shellwords.escape(value.to_s)
4306
+ "#{key}=#{rendered_value}"
4162
4307
  end
4163
4308
  (["env", *assignments].join(" ") + " " + Shellwords.join(command)).strip
4164
4309
  end
4165
4310
 
4311
+ def resume_command_secret_env_key?(key)
4312
+ key.match?(RESUME_COMMAND_SECRET_ENV_KEY_RE) && !key.match?(RESUME_COMMAND_SECRET_ENV_KEY_EXCLUDE_RE)
4313
+ end
4314
+
4166
4315
  def aggregate_github_release_event_line_handler(member)
4167
4316
  lambda do |line|
4168
4317
  event = parse_template_event(line)
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kettle-family
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.35
4
+ version: 1.3.37
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -57,6 +57,26 @@ dependencies:
57
57
  - - ">="
58
58
  - !ruby/object:Gem::Version
59
59
  version: 7.1.11
60
+ - !ruby/object:Gem::Dependency
61
+ name: ast-crispr-ruby-prism
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '7.1'
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: 7.1.10
70
+ type: :runtime
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - "~>"
75
+ - !ruby/object:Gem::Version
76
+ version: '7.1'
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: 7.1.10
60
80
  - !ruby/object:Gem::Dependency
61
81
  name: ast-crispr-toml
62
82
  requirement: !ruby/object:Gem::Requirement
@@ -114,7 +134,7 @@ dependencies:
114
134
  version: '1.0'
115
135
  - - ">="
116
136
  - !ruby/object:Gem::Version
117
- version: 1.0.14
137
+ version: 1.0.15
118
138
  type: :runtime
119
139
  prerelease: false
120
140
  version_requirements: !ruby/object:Gem::Requirement
@@ -124,7 +144,7 @@ dependencies:
124
144
  version: '1.0'
125
145
  - - ">="
126
146
  - !ruby/object:Gem::Version
127
- version: 1.0.14
147
+ version: 1.0.15
128
148
  - !ruby/object:Gem::Dependency
129
149
  name: kettle-deps-floor
130
150
  requirement: !ruby/object:Gem::Requirement
@@ -134,7 +154,7 @@ dependencies:
134
154
  version: '0.1'
135
155
  - - ">="
136
156
  - !ruby/object:Gem::Version
137
- version: 0.1.1
157
+ version: 0.1.2
138
158
  type: :runtime
139
159
  prerelease: false
140
160
  version_requirements: !ruby/object:Gem::Requirement
@@ -144,7 +164,7 @@ dependencies:
144
164
  version: '0.1'
145
165
  - - ">="
146
166
  - !ruby/object:Gem::Version
147
- version: 0.1.1
167
+ version: 0.1.2
148
168
  - !ruby/object:Gem::Dependency
149
169
  name: kettle-dev
150
170
  requirement: !ruby/object:Gem::Requirement
@@ -154,7 +174,7 @@ dependencies:
154
174
  version: '3.1'
155
175
  - - ">="
156
176
  - !ruby/object:Gem::Version
157
- version: 3.1.25
177
+ version: 3.1.26
158
178
  type: :runtime
159
179
  prerelease: false
160
180
  version_requirements: !ruby/object:Gem::Requirement
@@ -164,7 +184,7 @@ dependencies:
164
184
  version: '3.1'
165
185
  - - ">="
166
186
  - !ruby/object:Gem::Version
167
- version: 3.1.25
187
+ version: 3.1.26
168
188
  - !ruby/object:Gem::Dependency
169
189
  name: kettle-gha-pins
170
190
  requirement: !ruby/object:Gem::Requirement
@@ -174,7 +194,7 @@ dependencies:
174
194
  version: '0.3'
175
195
  - - ">="
176
196
  - !ruby/object:Gem::Version
177
- version: 0.3.19
197
+ version: 0.3.20
178
198
  type: :runtime
179
199
  prerelease: false
180
200
  version_requirements: !ruby/object:Gem::Requirement
@@ -184,7 +204,7 @@ dependencies:
184
204
  version: '0.3'
185
205
  - - ">="
186
206
  - !ruby/object:Gem::Version
187
- version: 0.3.19
207
+ version: 0.3.20
188
208
  - !ruby/object:Gem::Dependency
189
209
  name: kettle-jem
190
210
  requirement: !ruby/object:Gem::Requirement
@@ -214,7 +234,7 @@ dependencies:
214
234
  version: '0.1'
215
235
  - - ">="
216
236
  - !ruby/object:Gem::Version
217
- version: 0.1.22
237
+ version: 0.1.23
218
238
  type: :runtime
219
239
  prerelease: false
220
240
  version_requirements: !ruby/object:Gem::Requirement
@@ -224,7 +244,7 @@ dependencies:
224
244
  version: '0.1'
225
245
  - - ">="
226
246
  - !ruby/object:Gem::Version
227
- version: 0.1.22
247
+ version: 0.1.23
228
248
  - !ruby/object:Gem::Dependency
229
249
  name: kettle-test
230
250
  requirement: !ruby/object:Gem::Requirement
@@ -234,7 +254,7 @@ dependencies:
234
254
  version: '2.0'
235
255
  - - ">="
236
256
  - !ruby/object:Gem::Version
237
- version: 2.0.28
257
+ version: 2.0.29
238
258
  type: :runtime
239
259
  prerelease: false
240
260
  version_requirements: !ruby/object:Gem::Requirement
@@ -244,7 +264,7 @@ dependencies:
244
264
  version: '2.0'
245
265
  - - ">="
246
266
  - !ruby/object:Gem::Version
247
- version: 2.0.28
267
+ version: 2.0.29
248
268
  - !ruby/object:Gem::Dependency
249
269
  name: psych-merge
250
270
  requirement: !ruby/object:Gem::Requirement
@@ -608,10 +628,10 @@ licenses:
608
628
  - AGPL-3.0-only
609
629
  metadata:
610
630
  homepage_uri: https://kettle-family.galtzo.com
611
- source_code_uri: https://github.com/kettle-dev/kettle-family/tree/v1.3.35
612
- changelog_uri: https://github.com/kettle-dev/kettle-family/blob/v1.3.35/CHANGELOG.md
631
+ source_code_uri: https://github.com/kettle-dev/kettle-family/tree/v1.3.37
632
+ changelog_uri: https://github.com/kettle-dev/kettle-family/blob/v1.3.37/CHANGELOG.md
613
633
  bug_tracker_uri: https://github.com/kettle-dev/kettle-family/issues
614
- documentation_uri: https://www.rubydoc.info/gems/kettle-family/1.3.35
634
+ documentation_uri: https://www.rubydoc.info/gems/kettle-family/1.3.37
615
635
  funding_uri: https://github.com/sponsors/pboling
616
636
  wiki_uri: https://github.com/kettle-dev/kettle-family/wiki
617
637
  news_uri: https://www.railsbling.com/tags/kettle-family
metadata.gz.sig CHANGED
Binary file