kettle-family 1.3.5 → 1.3.6

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: 3e18a444e22f9d52b19ef8396ce75bc80b85bb579fb353889fecb8547ddc8bec
4
- data.tar.gz: b91bb8aa9018297be574e1eda7ddc26c6b8f314692cd4c4881c95fa4fe52062a
3
+ metadata.gz: 8d912f3b7a33740ace19f7f0568177189b662819bec18bf94312de460f640fd9
4
+ data.tar.gz: d70a03c67ed0a96ce645d00ff769a920b500b3d1d3b231324a2d8170ea683cf1
5
5
  SHA512:
6
- metadata.gz: 50d83077bd30c1c673772be79765f02d263cf499b382355f8b647889255abb9608155e8a0434d6c98d549ddad5188186f50e56b9608537e47b1cb5f2c1cfec5a
7
- data.tar.gz: e81978953b9a9d94d2c518d770cd72e6b6efbcd5a3fd2c010da97f72cb9107d71624bd126cdf1799c1e17276f3546adaca3246089e151b6bccc7edc04db4cef2
6
+ metadata.gz: ca24fe1d3dff219117a561ecd8ff6d45edd6c1a4d4fb6db9172c874f8964676162747de8d3d45fa0caf1aa7707ec2733141eda5a993bbbcbc8d0fd81d5f8543c
7
+ data.tar.gz: b3f48464d35121fdee21d6d23158e81630ac373676a6581d876f2887e735cc60d5cb70c4a87359090b124a56707b544d13dd1b30f5745ecf93ef81775eb03bed
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,27 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [1.3.6] - 2026-10-07
34
+
35
+ - TAG: [v1.3.6][1.3.6t]
36
+ - COVERAGE: 93.25% -- 6743/7231 lines in 38 files
37
+ - BRANCH COVERAGE: 77.82% -- 2740/3521 branches in 38 files
38
+ - 34.02% documented
39
+
40
+ ### Added
41
+
42
+ - [kc] published-version-check: `Kettle::Family::PublishedVersionCheck` detects lockfile pins that no registry serves — the signature of a locally built and installed gem leaking into a lockfile. Bundler resolves against the local gem dir, so an ordinary `bundle install` can pin such a version, and the entry it writes is a normal GEM-section line carrying a valid checksum taken from the installed spec, so nothing in the lockfile text distinguishes it and there is no offline marker either (installed specs look the same as fetched ones, and `Gem::Specification` has no `#remote`). Only asking the registry can tell. A pinned native gem is compared after splitting off its platform suffix, because a lockfile pins `nokogiri (1.19.4-x86_64-linux-gnu)` while the registry lists bare `1.19.4` once per platform; comparing the pinned string directly reported every native gem as unpublished, adding 10 false positives to one real lockfile. Prerelease versions are unaffected because they are dot-separated (`1.0.0.rc1`) and RubyGems version strings cannot contain a hyphen, so the first hyphen is always the version/platform boundary. Runs in `bup`/`bupb`/`update-bundler` before committing a lockfile update, and in release preflight, where a bad pin previously surfaced much later as an opaque bundler error during lockfile reset.
43
+
44
+ - [kc] published-version-memo: The published-version check fails open: when a registry cannot be consulted the version counts as unverifiable rather than unpublished, so a registry outage cannot turn every family member into a false failure. Registry answers are memoized per process keyed by gem, remote and version, so members sharing toolchain and sibling dependencies query the registry once for the whole run rather than once each. The memo is subordinate to the on-disk release marker that `kettle-release` writes after confirming a gem is available: a gem published during the current run is always re-queried rather than answered from a pre-publish memo entry, which would otherwise report the just-published version as unpublished and block a legitimate release.
45
+
46
+ - [kc] dependency-floor-published-version-gate: `kettle-family release` now gates the "Reconcile family dependencies" commit on the lockfile it is about to record. The preceding `bundle install` resolves against the local gem dir, so it can pin a locally built and installed version, and committing that produces a lockfile no isolated environment can resolve. The failure previously surfaced much later as an opaque bundler error during release lockfile reset ("Your bundle is locked to kettle-jem (7.1.29) ... but that version can no longer be found") rather than at the moment the bad pin was introduced; observed live, a release run's own reconcile commit flipped kettle-jem 7.1.28 to 7.1.29 in the tracked `Gemfile.lock` and then aborted at step 0. The existing floor diagnostics do not catch this, because they validate checksums only for released family members, while the pins that break resolution are typically toolchain dependencies that are not members at all and carry a valid checksum. Gems published during the same run are not blocked, since the check consults the on-disk release marker ahead of its process memo.
47
+
48
+ ### Fixed
49
+
50
+ - [kc] clean-unreleased-batch: `kettle-family clean-unreleased` now removes every unreleased installed version across the family in a single `gem uninstall` invocation instead of one invocation per gem. This is not merely fewer processes: RubyGems' `uninstall_specific` builds a `Gem::DependencyList` from every requested gem and removes them via `strongly_connected_components.flatten.reverse`, a topological sort of the whole set that removes dependents before their dependencies. A per-gem invocation sees only one gem and cannot sort, so it raises `Gem::DependencyRemovalException` ("Uninstallation aborted due to dependent gem(s)") whenever another installed gem still depends on it. Because family members are interdependent and the release order is dependency order, per-gem removal failed on the foundations: observed live, removing tree_haver first failed while ast-merge still required it, and four repeated passes still left two gems installed. One batched invocation removed both. Versions are passed as `name:version` arguments since RubyGems rejects `--version` alongside multiple gems, and `--all` is deliberately omitted because with gem arguments it is redundant while with none it means `uninstall_all`, which would remove every gem in the gem home.
51
+
52
+ - [kc] clean-unreleased-source-checkouts: `kettle-family clean-unreleased` no longer reports source checkouts as unreleased installed gems. Under `bundle exec`, bundler adds PATH and git sources to the load path, so `Gem::Specification.find_all_by_name` also returns specs whose `full_gem_path` points at a working copy rather than a gem home — for example tree_haver 7.1.10 resolving to `gems/tree_haver`. Those are not installed gems and `gem uninstall` cannot remove them, so the command reported work that could never succeed; after uninstalling all 31 genuinely installed unreleased gems in the structuredmerge family, it still listed all 31. Candidates are now filtered to specs under `Gem.path`.
53
+
33
54
  ## [1.3.5] - 2026-10-05
34
55
 
35
56
  - TAG: [v1.3.5][1.3.5t]
@@ -2884,7 +2905,9 @@ Please file a bug if you notice a violation of semantic versioning.
2884
2905
  - Fixed CI load failures on engines without compatible `pty` support by falling back to Open3 for interactive release commands.
2885
2906
  - Fixed Ruby 3.2 version-bump support by loading Prism lazily and wiring the Prism gem only for MRI versions that need it.
2886
2907
 
2887
- [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.3.5...HEAD
2908
+ [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.3.6...HEAD
2909
+ [1.3.6]: https://github.com/kettle-dev/kettle-family/compare/v1.3.5...v1.3.6
2910
+ [1.3.6t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.6
2888
2911
  [1.3.5]: https://github.com/kettle-dev/kettle-family/compare/v1.3.4...v1.3.5
2889
2912
  [1.3.5t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.5
2890
2913
  [1.3.4]: https://github.com/kettle-dev/kettle-family/compare/v1.3.3...v1.3.4
data/README.md CHANGED
@@ -1192,7 +1192,7 @@ Thanks for RTFM. ☺️
1192
1192
  [📌gitmoji]: https://gitmoji.dev
1193
1193
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
1194
1194
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
1195
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-7.123-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1195
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-7.231-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1196
1196
  [🔐security]: https://github.com/kettle-dev/kettle-family/blob/main/SECURITY.md
1197
1197
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
1198
1198
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kettle/dev/lockfile_reset"
4
+ require "kettle/dev/ruby_gems_versions"
5
+
6
+ module Kettle
7
+ module Family
8
+ # Detects versions pinned in a release lockfile that no registry actually
9
+ # serves — the signature of a locally built and installed gem leaking into
10
+ # a lockfile.
11
+ #
12
+ # Why this is needed at all: bundler resolves against the local gem dir, so
13
+ # an ordinary `bundle install` will happily pin a locally installed version.
14
+ # The resulting entry is a normal GEM-section line carrying a valid checksum
15
+ # taken from the installed spec, so it is indistinguishable from a published
16
+ # gem by inspection. There is also no offline marker for a local build —
17
+ # installed specs look the same as fetched ones, and Gem::Specification has
18
+ # no #remote — so the only reliable signal is asking the registry.
19
+ #
20
+ # Deliberately NOT applied to templating. Templating injects its own
21
+ # (often unreleased) version into a destination so it can run itself, and
22
+ # local templating from unreleased source is a routine workflow; gating that
23
+ # would break it. So this runs only where a lockfile is about to be
24
+ # committed or published, and templating's transient local pins get cleaned
25
+ # up later by a release-mode dependency update.
26
+ #
27
+ # Fails open: when the registry cannot be consulted the version counts as
28
+ # unverifiable rather than unpublished, so offline runs are never blocked by
29
+ # network conditions.
30
+ class PublishedVersionCheck
31
+ PHASE = "published_version_check"
32
+
33
+ # Process-local memo of registry answers, keyed by [gem name, remote].
34
+ #
35
+ # Two reasons this is safe and desirable:
36
+ #
37
+ # * Family members share almost all their toolchain and sibling
38
+ # dependencies, so one member's lookups serve every later member.
39
+ # Without this, a 30-member release re-queries the same gems 30
40
+ # times.
41
+ # * A single command invocation should see one consistent snapshot of
42
+ # the registry. Re-querying per member could otherwise report a gem
43
+ # as unpublished for early members and published for later ones if it
44
+ # lands mid-run.
45
+ #
46
+ # The memo is subordinate to the on-disk release marker, never above it.
47
+ # kettle-release writes ~/.local/state/kettle-dev/rubygems-cache-bust.json
48
+ # after publishing, so the filesystem is the authority on what this
49
+ # machine just released. A gem published *during* the current run — say
50
+ # kettle-dev 3.1.8, released before its dependents' floors are raised to
51
+ # it — would otherwise be answered from a pre-publish memo entry and
52
+ # falsely reported unpublished, blocking a legitimate release. So
53
+ # #published_versions always consults the marker first and skips the memo
54
+ # when it says that exact gem+version was just released. Verified: a
55
+ # pre-publish memo entry yields 0 live queries without the marker check
56
+ # and reports the new version missing.
57
+ #
58
+ # nil is memoized too, so an unreachable registry is asked once rather
59
+ # than once per pin. Lifetime is one process; nothing persists.
60
+ REGISTRY_MEMO = {}
61
+
62
+ def self.call(member:)
63
+ new(member: member).call
64
+ end
65
+
66
+ def initialize(member:)
67
+ @member = member
68
+ end
69
+
70
+ def call
71
+ result(diagnostics)
72
+ end
73
+
74
+ private
75
+
76
+ attr_reader :member
77
+
78
+ def diagnostics
79
+ lockfile = File.join(member.root, "Gemfile.lock")
80
+ return [] unless File.file?(lockfile)
81
+
82
+ registry_lockfile_specs(lockfile).filter_map { |spec| diagnostic_for(spec) }
83
+ end
84
+
85
+ def registry_lockfile_specs(lockfile)
86
+ Kettle::Dev::LockfileReset.registry_gem_specs_from_source(File.read(lockfile))
87
+ end
88
+
89
+ # A message for an unpublished pin, or nil when the version is published
90
+ # or the registry could not be consulted.
91
+ def diagnostic_for(spec)
92
+ return nil unless unpublished?(spec)
93
+
94
+ "release lockfile pins #{spec.fetch(:name)} #{spec.fetch(:version)}, " \
95
+ "which is not published on #{spec.fetch(:remote)} " \
96
+ "(locally installed but unreleased; run a release-mode dependency update to re-resolve)"
97
+ end
98
+
99
+ def unpublished?(spec)
100
+ remote = spec.fetch(:remote).to_s
101
+ return false if remote.empty?
102
+
103
+ pinned = version_without_platform(spec.fetch(:version))
104
+ versions = published_versions(spec.fetch(:name), remote, pinned)
105
+ return false if versions.nil?
106
+
107
+ !versions.include?(pinned)
108
+ end
109
+
110
+ # A lockfile pins a native gem as "1.19.4-x86_64-linux-gnu" while the
111
+ # registry lists its versions as bare "1.19.4", one entry per platform.
112
+ # Comparing the pinned string directly would report every native gem as
113
+ # unpublished, so split off the platform first.
114
+ #
115
+ # Splitting on the first "-" matches Bundler::LockfileParser. It is safe
116
+ # because RubyGems version strings cannot themselves contain a hyphen
117
+ # (prerelease segments are dot-separated, e.g. "1.0.0.rc1"), so the first
118
+ # hyphen is always the version/platform boundary.
119
+ def version_without_platform(pinned)
120
+ pinned.to_s.split("-", 2).first
121
+ end
122
+
123
+ # nil when the registry could not be consulted. Memoized per process,
124
+ # except for gems the on-disk release marker says were just published —
125
+ # see REGISTRY_MEMO for why bypassing the memo there is required.
126
+ def published_versions(name, remote, version)
127
+ key = [name, remote, version]
128
+ unless just_released?(name, version)
129
+ return REGISTRY_MEMO[key] if REGISTRY_MEMO.key?(key)
130
+ end
131
+
132
+ REGISTRY_MEMO[key] = Kettle::Dev::RubyGemsVersions.published_version_numbers(
133
+ name,
134
+ source: remote,
135
+ version: version
136
+ )
137
+ end
138
+
139
+ def just_released?(name, version)
140
+ return false if version.to_s.empty?
141
+
142
+ Kettle::Dev::RubyGemsVersions.recently_released?(name, version)
143
+ rescue
144
+ false
145
+ end
146
+
147
+ def result(found)
148
+ clean = found.empty?
149
+ CommandResult.new(
150
+ member_name: member.name,
151
+ phase: PHASE,
152
+ command: ["internal", PHASE],
153
+ workdir: member.root,
154
+ status: clean ? 0 : 1,
155
+ success: clean,
156
+ stdout: found.join("\n"),
157
+ stderr: "",
158
+ elapsed_seconds: 0.0,
159
+ skipped: false,
160
+ reason: clean ? nil : "lockfile pins versions no registry serves"
161
+ )
162
+ end
163
+ end
164
+ end
165
+ end
@@ -2,8 +2,49 @@
2
2
 
3
3
  module Kettle
4
4
  module Family
5
+ # Removes locally installed gem versions that no registry has published.
6
+ #
7
+ # Why this exists: bundler resolves against the local gem dir and picks the
8
+ # highest version satisfying a constraint, so a locally built unreleased
9
+ # version always wins over the published one. Floor constraints are `>=`, so
10
+ # there is no ceiling that stops it, and every `bundle install` or
11
+ # `bundle lock` re-pins the offender. Detection cannot fix resolution — the
12
+ # offenders have to be removed. Left in place they produce lockfiles that no
13
+ # isolated environment can resolve, which surfaces much later as an opaque
14
+ # bundler error ("locked to kettle-jem (7.1.29) ... can no longer be
15
+ # found").
16
+ #
17
+ # All candidates across the whole family are removed in ONE `gem uninstall`
18
+ # invocation rather than one invocation per gem. That is not merely fewer
19
+ # processes: RubyGems' `uninstall_specific` builds a Gem::DependencyList from
20
+ # every requested gem and removes them via
21
+ # `strongly_connected_components.flatten.reverse`, a topological sort of the
22
+ # whole set that removes dependents before their dependencies. A per-gem
23
+ # invocation sees only one gem and cannot sort, so it raises
24
+ # Gem::DependencyRemovalException ("Uninstallation aborted due to dependent
25
+ # gem(s)") whenever another installed gem still depends on it. Family
26
+ # members are heavily interdependent, so per-gem removal fails on the
27
+ # foundations: observed live, removing tree_haver first failed because
28
+ # ast-merge still required it, and four iterative passes still left two gems
29
+ # installed. One batched invocation removed both.
30
+ #
31
+ # The single run is still reported per member. Report keys results by member
32
+ # name for commands in MEMBER_RESULT_COMMANDS, and treats a member with no
33
+ # result as pending, which fails the whole summary. So attributing the batch
34
+ # to one synthetic family-level name left every member pending and reported
35
+ # `outcome: failure` with `failed: none`. Each contributing member instead
36
+ # receives the shared outcome of the one run.
37
+ #
38
+ # `--all` is deliberately not passed. With gem arguments present it is
39
+ # redundant (RubyGems routes to `uninstall_specific` either way), and with no
40
+ # arguments it means `uninstall_all`, which removes every gem in the gem
41
+ # home. Omitting it makes an empty argument list a plain usage error instead
42
+ # of a wipe. `--version` cannot be used either, because RubyGems rejects it
43
+ # alongside multiple gems; versions are given as `name:version` arguments,
44
+ # the form its own error message prescribes.
5
45
  class UnreleasedGemCleanup
6
46
  PHASE = "clean_unreleased"
47
+ UNKNOWN_RELEASED_MESSAGE = "latest released version is unknown; no cleanup attempted"
7
48
 
8
49
  def initialize(config:, members:, execute: false, runner: nil)
9
50
  @config = config
@@ -14,9 +55,24 @@ module Kettle
14
55
 
15
56
  def results
16
57
  release_states = release_state_by_member
17
- members.flat_map do |member|
18
- cleanup_results_for(member: member, release_state: release_states[member.name])
58
+ # Resolved once per member: candidates and the member's non-uninstall
59
+ # diagnostics both derive from the same release state and installed spec
60
+ # scan, so pairing them in one pass keeps installed specs enumerated a
61
+ # single time and avoids re-reading release_states per member.
62
+ planned = members.map do |member|
63
+ release_state = release_states[member.name]
64
+ candidates = unreleased_candidates(member: member, release_state: release_state)
65
+ {
66
+ member: member,
67
+ candidates: candidates,
68
+ diagnostics: member_diagnostics(member: member, release_state: release_state, candidates: candidates)
69
+ }
19
70
  end
71
+ diagnostics = planned.flat_map { |entry| entry.fetch(:diagnostics) }
72
+ contributors = planned.reject { |entry| entry.fetch(:candidates).empty? }
73
+ return diagnostics if contributors.empty?
74
+
75
+ diagnostics + batched_cleanup_results(contributors)
20
76
  end
21
77
 
22
78
  private
@@ -29,16 +85,40 @@ module Kettle
29
85
  end
30
86
  end
31
87
 
32
- def cleanup_results_for(member:, release_state:)
88
+ # Per-member outcomes that are not an uninstall: a release state that could
89
+ # not be read, a version that cannot be compared, or nothing to do. These
90
+ # stay per member so a failure names the member it came from. Members that
91
+ # do have candidates are reported by the batch result instead.
92
+ def member_diagnostics(member:, release_state:, candidates:)
33
93
  return [release_state_failure(member: member, release_state: release_state)] unless release_state&.ok?
34
94
 
35
95
  latest_released = latest_released_version(release_state.state)
36
- return [informational_result(member: member, message: "latest released version is unknown; no cleanup attempted")] unless latest_released
96
+ return [informational_result(member: member, message: UNKNOWN_RELEASED_MESSAGE)] unless latest_released
97
+ return [] unless candidates.empty?
98
+
99
+ [informational_result(member: member, message: "no unreleased installed versions found")]
100
+ end
37
101
 
38
- candidates = installed_versions(member.name).select { |version| version > latest_released }
39
- return [informational_result(member: member, message: "no unreleased installed versions found")] if candidates.empty?
102
+ # Installed versions newer than anything published, as [name, version] pairs.
103
+ #
104
+ # Only genuinely installed gems count. Under `bundle exec`, bundler adds
105
+ # PATH and git sources to the load path, so
106
+ # Gem::Specification.find_all_by_name also returns specs whose
107
+ # full_gem_path points at a source checkout rather than a gem home — e.g.
108
+ # tree_haver 7.1.10 resolving to gems/tree_haver. Those are not installed
109
+ # gems and `gem uninstall` cannot remove them, so treating them as
110
+ # candidates would report work that can never succeed. Filtering on
111
+ # Gem.path membership excludes them.
112
+ def unreleased_candidates(member:, release_state:)
113
+ return [] unless release_state&.ok?
114
+
115
+ latest_released = latest_released_version(release_state.state)
116
+ return [] unless latest_released
40
117
 
41
- candidates.map { |version| cleanup_result(member: member, version: version) }
118
+ name = member.name
119
+ installed_versions(name).filter_map do |version|
120
+ [name, version] if version > latest_released
121
+ end
42
122
  end
43
123
 
44
124
  def latest_released_version(state)
@@ -51,21 +131,50 @@ module Kettle
51
131
  end
52
132
 
53
133
  def installed_versions(name)
54
- Gem::Specification.find_all_by_name(name).map(&:version).uniq.sort
134
+ Gem::Specification.find_all_by_name(name)
135
+ .select { |spec| installed_spec?(spec) }
136
+ .map(&:version)
137
+ .uniq
138
+ .sort
55
139
  end
56
140
 
57
- def cleanup_result(member:, version:)
58
- command = ["gem", "uninstall", member.name, "--version", version.to_s, "--executables", "--all"]
59
- return runner.call(member: member, phase: PHASE, command: command) if execute
141
+ def installed_spec?(spec)
142
+ path = spec.full_gem_path.to_s
143
+ return false if path.empty?
144
+
145
+ Gem.path.any? { |root| path.start_with?("#{root}#{File::SEPARATOR}") }
146
+ rescue
147
+ false
148
+ end
60
149
 
150
+ # Runs the batch once, then reports that one outcome against every member
151
+ # whose gems were in it. See the class comment for why per-member results
152
+ # are required even though only one command runs.
153
+ def batched_cleanup_results(contributors)
154
+ candidates = contributors.flat_map { |entry| entry.fetch(:candidates) }
155
+ # Versions are given as `name:version` arguments rather than `--version`,
156
+ # which RubyGems rejects alongside multiple gems. See the class comment
157
+ # for why `--all` is omitted.
158
+ command = ["gem", "uninstall", *candidates.map { |name, version| "#{name}:#{version}" }, "--executables"]
159
+ # gem uninstall does not depend on the working directory, so the first
160
+ # contributing member provides a real directory to execute in.
161
+ host = contributors.first.fetch(:member)
162
+ outcome = execute ? runner.call(member: host, phase: PHASE, command: command) : dry_run_outcome(command)
163
+
164
+ contributors.map do |entry|
165
+ member_result(entry.fetch(:member), outcome, entry.fetch(:candidates))
166
+ end
167
+ end
168
+
169
+ def dry_run_outcome(command)
61
170
  CommandResult.new(
62
- member_name: member.name,
171
+ member_name: nil,
63
172
  phase: PHASE,
64
173
  command: command,
65
- workdir: member.root,
174
+ workdir: nil,
66
175
  status: nil,
67
176
  success: true,
68
- stdout: "would uninstall #{member.name} #{version}",
177
+ stdout: nil,
69
178
  stderr: "",
70
179
  elapsed_seconds: 0.0,
71
180
  skipped: true,
@@ -73,6 +182,32 @@ module Kettle
73
182
  )
74
183
  end
75
184
 
185
+ # One member's view of the shared batch outcome. The command and exit state
186
+ # are common to the whole batch, but each member reports only its own gems,
187
+ # so the per-member report stays readable and a failure names every gem
188
+ # that member contributed.
189
+ def member_result(member, outcome, own_candidates)
190
+ own = describe(own_candidates)
191
+ outcome_reason = outcome.reason
192
+ # Equivalent to `ok? ? reason : (own.empty? ? reason : batch message)`,
193
+ # with outcome.reason read once instead of twice.
194
+ reason = if outcome.ok? || own_candidates.empty?
195
+ outcome_reason
196
+ else
197
+ "batched gem uninstall including #{own} failed"
198
+ end
199
+ outcome.dup.tap do |copy|
200
+ copy.member_name = member.name
201
+ copy.workdir = member.root
202
+ copy.stdout = outcome.skipped ? "would uninstall #{own}" : outcome.stdout
203
+ copy.reason = reason
204
+ end
205
+ end
206
+
207
+ def describe(candidates)
208
+ candidates.map { |name, version| "#{name} #{version}" }.join(", ")
209
+ end
210
+
76
211
  def informational_result(member:, message:)
77
212
  CommandResult.new(
78
213
  member_name: member.name,
@@ -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.5"
8
+ VERSION = "1.3.6"
9
9
  end
10
10
  # Current gem version exposed at the traditional constant location.
11
11
  VERSION = Version::VERSION # Traditional Constant Location
@@ -2343,9 +2343,9 @@ module Kettle
2343
2343
  return memo
2344
2344
  end
2345
2345
 
2346
- append_release_internal_checks(member: member, memo: memo)
2347
- memo.last(2).each { |result| emit_member_result_progress(original_member, result, progress: progress) }
2348
- return memo unless memo.last(2).all?(&:ok?)
2346
+ internal_check_results = append_release_internal_checks(member: member, memo: memo)
2347
+ internal_check_results.each { |result| emit_member_result_progress(original_member, result, progress: progress) }
2348
+ return memo unless internal_check_results.all?(&:ok?)
2349
2349
 
2350
2350
  release_event_state = {}
2351
2351
  result = release_command_runner.call(
@@ -2526,9 +2526,9 @@ module Kettle
2526
2526
  end
2527
2527
  end
2528
2528
 
2529
- append_release_internal_checks(member: member, memo: memo)
2530
- memo.last(2).each { |result| emit_member_result_progress(member, result, progress: progress) }
2531
- return memo unless memo.last(2).all?(&:ok?)
2529
+ internal_check_results = append_release_internal_checks(member: member, memo: memo)
2530
+ internal_check_results.each { |result| emit_member_result_progress(member, result, progress: progress) }
2531
+ return memo unless internal_check_results.all?(&:ok?)
2532
2532
 
2533
2533
  release_event_state = {}
2534
2534
  release_result = runner.call(
@@ -2744,7 +2744,38 @@ module Kettle
2744
2744
  end
2745
2745
 
2746
2746
  reconciled_members = (floor_results.map(&:member_name) + lockfile_refresh_members.map { |member, _released_members| member.name }).uniq
2747
- commit_dependency_floor_changes(dependent_members: reconciled_members, runner: runner, memo: memo) if reconciled_members.any? && execute && commit
2747
+ return unless reconciled_members.any? && execute && commit
2748
+
2749
+ return unless validate_dependency_floor_published_versions(member_names: reconciled_members, memo: memo)
2750
+
2751
+ commit_dependency_floor_changes(dependent_members: reconciled_members, runner: runner, memo: memo)
2752
+ end
2753
+
2754
+ # Gates the "Reconcile family dependencies" commit on the lockfile it is
2755
+ # about to record.
2756
+ #
2757
+ # The preceding bundle install resolves against the local gem dir, so it
2758
+ # happily pins a locally built and installed version. Committing that
2759
+ # produces a lockfile no isolated environment can resolve, and the failure
2760
+ # surfaces much later as an opaque bundler error during release lockfile
2761
+ # reset ("locked to kettle-jem (7.1.29) ... can no longer be found")
2762
+ # rather than at the moment the bad pin was introduced. Observed live: a
2763
+ # release run's own reconcile commit flipped kettle-jem 7.1.28 -> 7.1.29
2764
+ # in the tracked Gemfile.lock and then aborted at step 0.
2765
+ #
2766
+ # Gems published during this very run are not a false positive, because
2767
+ # PublishedVersionCheck consults kettle-release's on-disk release marker
2768
+ # ahead of its process memo, and that marker is written only after the gem
2769
+ # is confirmed available on the registry.
2770
+ def validate_dependency_floor_published_versions(member_names:, memo:)
2771
+ by_name = members.to_h { |member| [member.name, member] }
2772
+ results = member_names.filter_map do |member_name|
2773
+ member = by_name[member_name]
2774
+ next unless member
2775
+
2776
+ PublishedVersionCheck.call(member: member).tap { |result| memo << result }
2777
+ end
2778
+ results.all?(&:ok?)
2748
2779
  end
2749
2780
 
2750
2781
  def dependent_members_depending_on(released_members:, dependent_members:)
@@ -3275,13 +3306,34 @@ module Kettle
3275
3306
  results.each { |result| result.branch = branch if result.respond_to?(:branch=) }
3276
3307
  end
3277
3308
 
3309
+ # Runs the internal pre-release checks for one member, appending each
3310
+ # result to +memo+. Returns the results appended by this call so callers
3311
+ # can inspect exactly them instead of relying on a positional
3312
+ # `memo.last(N)` window.
3313
+ #
3314
+ # Short-circuits on the first failure: a member with a broken lockfile
3315
+ # should not spend a release on its changelog, and the returned array
3316
+ # then holds only the failing result.
3278
3317
  def append_release_internal_checks(member:, memo:)
3279
- memo << ReadinessCheck.call(
3318
+ appended = []
3319
+ readiness = ReadinessCheck.call(
3280
3320
  member: member,
3281
3321
  config: config,
3282
3322
  allowed_local_path_roots: release_readiness_allowed_local_path_roots(member)
3283
3323
  )
3284
- memo << ChangelogCheck.call(member: member, config: config) if memo.last.ok?
3324
+ memo << readiness
3325
+ appended << readiness
3326
+ return appended unless readiness.ok?
3327
+
3328
+ published_versions = PublishedVersionCheck.call(member: member)
3329
+ memo << published_versions
3330
+ appended << published_versions
3331
+ return appended unless published_versions.ok?
3332
+
3333
+ changelog = ChangelogCheck.call(member: member, config: config)
3334
+ memo << changelog
3335
+ appended << changelog
3336
+ appended
3285
3337
  end
3286
3338
 
3287
3339
  def release_readiness_allowed_local_path_roots(member)
@@ -4597,8 +4649,16 @@ module Kettle
4597
4649
  total
4598
4650
  end
4599
4651
 
4652
+ # Progress denominator for one member's release. The base four are
4653
+ # check, published_version_check, release_changelog and release_build.
4654
+ #
4655
+ # This must stay in step with what append_release_internal_checks emits:
4656
+ # Progress#increment_member_count clamps the count to the total, so a
4657
+ # total that is too low silently saturates the progress display instead
4658
+ # of failing. Adding a release-internal phase without raising this base
4659
+ # would leave every member showing its final count early.
4600
4660
  def release_phase_total(member = nil)
4601
- total = 3
4661
+ total = 4
4602
4662
  if member
4603
4663
  normalize = normalize_release_lockfiles?(member)
4604
4664
  total += 2 if normalize
@@ -5723,8 +5783,14 @@ module Kettle
5723
5783
  end
5724
5784
 
5725
5785
  def release_lockfile_readiness_would_fail?(member)
5726
- result = ReadinessCheck.call(member: member, config: config, allowed_local_path_roots: release_allowed_local_path_roots)
5727
- result.stdout.lines.any? { |line| line.start_with?("release lockfile has local path remote at ") }
5786
+ readiness = ReadinessCheck.call(
5787
+ member: member,
5788
+ config: config,
5789
+ allowed_local_path_roots: release_allowed_local_path_roots
5790
+ )
5791
+ return true if readiness.stdout.lines.any? { |line| line.start_with?("release lockfile has local path remote at ") }
5792
+
5793
+ !PublishedVersionCheck.call(member: member).ok?
5728
5794
  end
5729
5795
 
5730
5796
  def release_lockfile_has_local_path_remote?(member)
@@ -5975,11 +6041,14 @@ module Kettle
5975
6041
  end
5976
6042
 
5977
6043
  def bundle_update_lockfile_diagnostics(member)
5978
- ReadinessCheck.call(
6044
+ readiness = ReadinessCheck.call(
5979
6045
  member: member,
5980
6046
  config: config,
5981
6047
  allowed_local_path_roots: bundle_update_allowed_local_path_roots
5982
- ).stdout.lines.grep(/^release lockfile has local path remote at /).map do |line|
6048
+ ).stdout.lines.grep(/^release lockfile has local path remote at /)
6049
+ unpublished = PublishedVersionCheck.call(member: member).stdout.lines
6050
+
6051
+ (readiness + unpublished).map do |line|
5983
6052
  line.sub("release lockfile", "bundle update lockfile").chomp
5984
6053
  end
5985
6054
  end
data/lib/kettle/family.rb CHANGED
@@ -5,6 +5,7 @@ require_relative "family/command_result"
5
5
  require_relative "family/release_state_result"
6
6
  require_relative "family/command_runner"
7
7
  require_relative "family/readiness_check"
8
+ require_relative "family/published_version_check"
8
9
  require_relative "family/changelog_check"
9
10
  require_relative "family/git_status"
10
11
  require_relative "family/branch_lane_audit"
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.5
4
+ version: 1.3.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -94,7 +94,7 @@ dependencies:
94
94
  version: '3.1'
95
95
  - - ">="
96
96
  - !ruby/object:Gem::Version
97
- version: 3.1.7
97
+ version: 3.1.8
98
98
  type: :runtime
99
99
  prerelease: false
100
100
  version_requirements: !ruby/object:Gem::Requirement
@@ -104,7 +104,7 @@ dependencies:
104
104
  version: '3.1'
105
105
  - - ">="
106
106
  - !ruby/object:Gem::Version
107
- version: 3.1.7
107
+ version: 3.1.8
108
108
  - !ruby/object:Gem::Dependency
109
109
  name: kettle-gha-pins
110
110
  requirement: !ruby/object:Gem::Requirement
@@ -436,6 +436,7 @@ files:
436
436
  - lib/kettle/family/nomono_bootstrap.rb
437
437
  - lib/kettle/family/orderer.rb
438
438
  - lib/kettle/family/paths.rb
439
+ - lib/kettle/family/published_version_check.rb
439
440
  - lib/kettle/family/readiness_check.rb
440
441
  - lib/kettle/family/release_graph.rb
441
442
  - lib/kettle/family/release_reconciler.rb
@@ -459,10 +460,10 @@ licenses:
459
460
  - AGPL-3.0-only
460
461
  metadata:
461
462
  homepage_uri: https://kettle-family.galtzo.com
462
- source_code_uri: https://github.com/kettle-dev/kettle-family/tree/v1.3.5
463
- changelog_uri: https://github.com/kettle-dev/kettle-family/blob/v1.3.5/CHANGELOG.md
463
+ source_code_uri: https://github.com/kettle-dev/kettle-family/tree/v1.3.6
464
+ changelog_uri: https://github.com/kettle-dev/kettle-family/blob/v1.3.6/CHANGELOG.md
464
465
  bug_tracker_uri: https://github.com/kettle-dev/kettle-family/issues
465
- documentation_uri: https://www.rubydoc.info/gems/kettle-family/1.3.5
466
+ documentation_uri: https://www.rubydoc.info/gems/kettle-family/1.3.6
466
467
  funding_uri: https://github.com/sponsors/pboling
467
468
  wiki_uri: https://github.com/kettle-dev/kettle-family/wiki
468
469
  news_uri: https://www.railsbling.com/tags/kettle-family
metadata.gz.sig CHANGED
Binary file