kettle-dev 1.1.24 → 1.1.26

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: d04b8ab4f92685e809649cfa00e1272adb8b3da37ac68b24034259f7a7e7052b
4
- data.tar.gz: ab03c72bf61c27941a1592c5e7bd47f0c4fc0211bc2d04280b809a6ba4d1d0b9
3
+ metadata.gz: d5b1991c0a766cdf2ca22899493b377918b1c35904e8c7927e29c30ba927df02
4
+ data.tar.gz: 9ba2c426beb96666c08ad9d0462162921456d48c8c2ce2ece0d302a62ab6574e
5
5
  SHA512:
6
- metadata.gz: 33c386f8d38cbf5c6b87f54a56d133cf7d29f5265c1dd2279a27825a3c149a9d23293bbf71e9e359de952f812d00481c603255d8d7f1512e27edc9f14bedeca6
7
- data.tar.gz: 0e9766d1e8746e056d1faece88d7df6542122af64d9cc789229b7881badca3c28b31f18acdc4eb4fbc080ad583000d6e906541f81867dfb8181732e828609ae1
6
+ metadata.gz: 1d688be0bf828e8d633970b38b37f0cc386afeb6718ddb7578f09f22f16ddd6e25b55b24978b14e9ba0d7e90f3aad4eef36fb394680f86e34195982e8f9d81f6
7
+ data.tar.gz: eee66b8b680aea507855b8b6323769e2e9f4d297243f0ed17a84261798241749d76a565dcf18dc2ba6730717aeb7701515b353fa9520f7add4bd37f99f0924ef
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -22,14 +22,29 @@ Please file a bug if you notice a violation of semantic versioning.
22
22
 
23
23
  ### Changed
24
24
 
25
+ - Use obfuscated URLs, and avatars from Open Collective in ReadmeBackers
26
+
25
27
  ### Deprecated
26
28
 
27
29
  ### Removed
28
30
 
29
31
  ### Fixed
30
32
 
33
+ - fixed handling of kettle-release when checksums are present and unchanged causing the gem_checksums script to fail
34
+
31
35
  ### Security
32
36
 
37
+ ## [1.1.25] - 2025-09-18
38
+
39
+ - TAG: [v1.1.25][1.1.25t]
40
+ - COVERAGE: 96.87% -- 3708/3828 lines in 26 files
41
+ - BRANCH COVERAGE: 81.69% -- 1526/1868 branches in 26 files
42
+ - 78.33% documented
43
+
44
+ ### Fixed
45
+
46
+ - kettle-readme-backers fails gracefully when README_UPDATER_TOKEN is missing from org secrets
47
+
33
48
  ## [1.1.24] - 2025-09-17
34
49
 
35
50
  - TAG: [v1.1.24][1.1.24t]
@@ -969,7 +984,11 @@ Please file a bug if you notice a violation of semantic versioning.
969
984
  - Selecting will run the selected workflow via `act`
970
985
  - This may move to its own gem in the future.
971
986
 
972
- [Unreleased]: https://github.com/kettle-rb/kettle-dev/compare/v1.1.24...HEAD
987
+ [Unreleased]: https://github.com/kettle-rb/kettle-dev/compare/v1.1.26...HEAD
988
+ [1.1.26]: https://github.com/kettle-rb/kettle-dev/compare/v1.1.25...v1.1.26
989
+ [1.1.26t]: https://github.com/kettle-rb/kettle-dev/releases/tag/v1.1.26
990
+ [1.1.25]: https://github.com/kettle-rb/kettle-dev/compare/v1.1.24...v1.1.25
991
+ [1.1.25t]: https://github.com/kettle-rb/kettle-dev/releases/tag/v1.1.25
973
992
  [1.1.24]: https://github.com/kettle-rb/kettle-dev/compare/v1.1.23...v1.1.24
974
993
  [1.1.24t]: https://github.com/kettle-rb/kettle-dev/releases/tag/v1.1.24
975
994
  [1.1.23]: https://github.com/kettle-rb/kettle-dev/compare/v1.1.22...v1.1.23
data/README.md CHANGED
@@ -548,6 +548,11 @@ What it does:
548
548
  - Tip:
549
549
  - Run this locally before committing to keep your README current, or schedule it in CI to refresh periodically.
550
550
  - It runs automatically on a once-a-week schedule by the .github/workflows/opencollective.yml workflow that is part of the kettle-dev template.
551
+ - Authentication requirement:
552
+ - When running in CI with the provided workflow, you must provide an organization-level Actions secret named `README_UPDATER_TOKEN`.
553
+ - Create it under your GitHub organization settings: `https://github.com/organizations/<YOUR_ORG>/settings/secrets/actions`.
554
+ - The updater will look for `REPO` or `GITHUB_REPOSITORY` (both usually set by GitHub Actions) to infer `<YOUR_ORG>` for guidance.
555
+ - If `README_UPDATER_TOKEN` is missing, the tool prints a helpful error to STDERR and aborts, including a direct link to the expected org settings page.
551
556
 
552
557
  ## 🦷 FLOSS Funding
553
558
 
@@ -574,20 +579,24 @@ and [Tidelift][🏙️entsup-tidelift].
574
579
 
575
580
  ### Open Collective for Individuals
576
581
 
582
+ Support us with a monthly donation and help us continue our activities. [[Become a backer](https://opencollective.com/kettle-rb#backer)]
583
+
584
+ NOTE: [kettle-readme-backers][kettle-readme-backers] updates this list every day, automatically.
585
+
577
586
  <!-- OPENCOLLECTIVE-INDIVIDUALS:START -->
578
587
  No backers yet. Be the first!
579
588
  <!-- OPENCOLLECTIVE-INDIVIDUALS:END -->
580
589
 
581
- Support us with a monthly donation and help us continue our activities. [[Become a backer](https://opencollective.com/kettle-rb#backer)]
582
-
583
590
  ### Open Collective for Organizations
584
591
 
592
+ Become a sponsor and get your logo on our README on GitHub with a link to your site. [[Become a sponsor](https://opencollective.com/kettle-rb#sponsor)]
593
+
594
+ NOTE: [kettle-readme-backers][kettle-readme-backers] updates this list every day, automatically.
595
+
585
596
  <!-- OPENCOLLECTIVE-ORGANIZATIONS:START -->
586
597
  No sponsors yet. Be the first!
587
598
  <!-- OPENCOLLECTIVE-ORGANIZATIONS:END -->
588
599
 
589
- Become a sponsor and get your logo on our README on GitHub with a link to your site. [[Become a sponsor](https://opencollective.com/kettle-rb#sponsor)]
590
-
591
600
  ### Another way to support open-source
592
601
 
593
602
  > How wonderful it is that nobody need wait a single moment before starting to improve the world.<br/>
@@ -916,7 +925,7 @@ Thanks for RTFM. ☺️
916
925
  [📌gitmoji]:https://gitmoji.dev
917
926
  [📌gitmoji-img]:https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
918
927
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
919
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-3.814-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
928
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-1.225-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
920
929
  [🔐security]: SECURITY.md
921
930
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
922
931
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
data/README.md.example CHANGED
@@ -166,19 +166,25 @@ and [Tidelift][🏙️entsup-tidelift].
166
166
 
167
167
  ### Open Collective for Individuals
168
168
 
169
+ Support us with a monthly donation and help us continue our activities. [[Become a backer](https://opencollective.com/kettle-rb#backer)]
170
+
171
+ NOTE: [kettle-readme-backers][kettle-readme-backers] updates this list every day, automatically.
172
+
169
173
  <!-- OPENCOLLECTIVE-INDIVIDUALS:START -->
170
174
  No backers yet. Be the first!
171
175
  <!-- OPENCOLLECTIVE-INDIVIDUALS:END -->
172
176
 
173
- Support us with a monthly donation and help us continue our activities. [[Become a backer](https://opencollective.com/kettle-rb#backer)]
174
-
175
177
  ### Open Collective for Organizations
176
178
 
179
+ Become a sponsor and get your logo on our README on GitHub with a link to your site. [[Become a sponsor](https://opencollective.com/kettle-rb#sponsor)]
180
+
181
+ NOTE: [kettle-readme-backers][kettle-readme-backers] updates this list every day, automatically.
182
+
177
183
  <!-- OPENCOLLECTIVE-ORGANIZATIONS:START -->
178
184
  No sponsors yet. Be the first!
179
185
  <!-- OPENCOLLECTIVE-ORGANIZATIONS:END -->
180
186
 
181
- Become a sponsor and get your logo on our README on GitHub with a link to your site. [[Become a sponsor](https://opencollective.com/kettle-rb#sponsor)]
187
+ [kettle-readme-backers]: https://github.com/kettle-rb/kettle-dev/blob/main/exe/kettle-readme-backers
182
188
 
183
189
  ### Another way to support open-source
184
190
 
@@ -513,7 +519,7 @@ Thanks for RTFM. ☺️
513
519
  [📌gitmoji]:https://gitmoji.dev
514
520
  [📌gitmoji-img]:https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
515
521
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
516
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-3.814-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
522
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-1.225-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
517
523
  [🔐security]: SECURITY.md
518
524
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
519
525
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
data/Rakefile.example CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # kettle-dev Rakefile v1.1.24 - 2025-09-17
3
+ # kettle-dev Rakefile v1.1.26 - 2025-09-20
4
4
  # Ruby 2.3 (Safe Navigation) or higher required
5
5
  #
6
6
  # MIT License (see License.txt)
@@ -18,26 +18,44 @@ module Kettle
18
18
 
19
19
  public
20
20
 
21
- DEFAULT_AVATAR = "https://opencollective.com/static/images/default-avatar.png"
22
- README_PATH = File.expand_path("../../../README.md", __dir__)
21
+ # Default README is the one in the current working directory of the host project
22
+ README_PATH = File.expand_path("README.md", Dir.pwd)
23
23
  README_OSC_TAG_DEFAULT = "OPENCOLLECTIVE"
24
24
  COMMIT_SUBJECT_DEFAULT = "💸 Thanks 🙏 to our new backers 🎒 and subscribers 📜"
25
25
  # Deprecated constant maintained for backwards compatibility in tests/specs.
26
- # Prefer OpenCollectiveConfig.yaml_path going forward.
27
- OC_YML_PATH = OpenCollectiveConfig.yaml_path
26
+ # Prefer OpenCollectiveConfig.yaml_path going forward, but resolve to the host project root.
27
+ OC_YML_PATH = OpenCollectiveConfig.yaml_path(Dir.pwd)
28
+
29
+ private
30
+
31
+ # Emit a debug log line when kettle-dev debugging is enabled.
32
+ # Controlled by KETTLE_DEV_DEBUG=true (or DEBUG=true as fallback).
33
+ # @param msg [String]
34
+ # @return [void]
35
+ def debug_log(msg)
36
+ return unless Kettle::Dev::DEBUGGING
37
+ Kernel.warn("[readme_backers] #{msg}")
38
+ rescue StandardError
39
+ # never raise from a standard error within debug logging
40
+ end
41
+
42
+ public
28
43
 
29
44
  # Ruby 2.3 compatibility: Struct keyword_init added in Ruby 2.5
30
45
  # Switch to struct when dropping ruby < 2.5
31
46
  # Backer = Struct.new(:name, :image, :website, :profile, keyword_init: true)
32
47
  # Fallback for Ruby < 2.5 where Struct keyword_init is unsupported
33
48
  class Backer
34
- attr_accessor :name, :image, :website, :profile
49
+ ROLE = "BACKER"
50
+ attr_accessor :name, :image, :website, :profile, :oc_type, :oc_index
35
51
 
36
- def initialize(name: nil, image: nil, website: nil, profile: nil, **_ignored)
52
+ def initialize(name: nil, image: nil, website: nil, profile: nil, oc_type: nil, oc_index: nil, **_ignored)
37
53
  @name = name
38
54
  @image = image
39
55
  @website = website
40
56
  @profile = profile
57
+ @oc_type = oc_type # "backer" or "organization"
58
+ @oc_index = oc_index # Integer index within type for OC URL generation
41
59
  end
42
60
  end
43
61
 
@@ -46,25 +64,111 @@ module Kettle
46
64
  @readme_path = readme_path
47
65
  end
48
66
 
67
+ # Validate environment preconditions for running the updater.
68
+ # Ensures README_UPDATER_TOKEN is present. If missing, prints guidance and raises.
69
+ #
70
+ # @return [void]
71
+ # @raise [RuntimeError] when README_UPDATER_TOKEN is not provided
72
+ def validate
73
+ token = ENV["README_UPDATER_TOKEN"].to_s
74
+ if token.strip.empty?
75
+ repo = ENV["REPO"] || ENV["GITHUB_REPOSITORY"]
76
+ org = repo&.to_s&.split("/")&.first
77
+ org_url = if org && !org.strip.empty?
78
+ "https://github.com/organizations/#{org}/settings/secrets/actions"
79
+ else
80
+ "https://github.com/organizations/YOUR_ORG/settings/secrets/actions"
81
+ end
82
+ $stderr.puts "ERROR: README_UPDATER_TOKEN is not set."
83
+ $stderr.puts "Please create an organization-level Actions secret named README_UPDATER_TOKEN at:"
84
+ $stderr.puts " #{org_url}"
85
+ $stderr.puts "Then update the workflow to reference it, or provide README_UPDATER_TOKEN in the environment."
86
+ raise 'Missing ENV["README_UPDATER_TOKEN"]'
87
+ end
88
+ nil
89
+ end
90
+
49
91
  def run!
92
+ validate
93
+ debug_log("Starting run: handle=#{@handle.inspect}, readme=#{@readme_path}")
94
+ debug_log("Resolved OSC tag base=#{readme_osc_tag.inspect}")
50
95
  readme = File.read(@readme_path)
51
96
 
52
97
  # Identify previous entries for diffing/mentions
53
98
  b_start, b_end = detect_backer_tags(readme)
54
- prev_backer_identities = extract_section_identities(readme, b_start, b_end)
55
99
  s_start_prev, s_end_prev = detect_sponsor_tags(readme)
100
+ debug_log("Backer tags present=#{b_start != :not_found && b_end != :not_found}; Sponsor tags present=#{s_start_prev != :not_found && s_end_prev != :not_found}")
101
+ prev_backer_identities = extract_section_identities(readme, b_start, b_end)
56
102
  prev_sponsor_identities = extract_section_identities(readme, s_start_prev, s_end_prev)
57
103
 
58
- # Backers (individuals)
59
- backers = fetch_members("backers.json")
104
+ # Fetch all BACKER-role members once and partition by tier
105
+ debug_log("Fetching OpenCollective members JSON for handle=#{@handle} ...")
106
+ raw = fetch_all_backers_raw
107
+ debug_log("Fetched #{Array(raw).size} members (role=#{Backer::ROLE}) before tier partitioning")
108
+ # Build OpenCollective type-index map to generate stable avatar/website links
109
+ index_map = build_oc_index_map(raw)
110
+ if Kettle::Dev::DEBUGGING
111
+ tier_counts = Array(raw).group_by { |h| (h["tier"] || "").to_s.strip }.transform_values(&:size)
112
+ debug_log("Tier distribution: #{tier_counts}")
113
+ empty_tier = Array(raw).select { |h| h["tier"].to_s.strip.empty? }
114
+ unless empty_tier.empty?
115
+ debug_log("Members with empty tier: count=#{empty_tier.size}; showing up to 5 samples:")
116
+ empty_tier.first(5).each_with_index do |m, i|
117
+ debug_log(" [empty-tier ##{i + 1}] name=#{m["name"].inspect}, isActive=#{m["isActive"].inspect}, profile=#{m["profile"].inspect}, website=#{m["website"].inspect}")
118
+ end
119
+ end
120
+ other_tiers = Array(raw).map { |h| h["tier"].to_s.strip }.reject { |t| t.empty? || t.casecmp("Backer").zero? || t.casecmp("Sponsor").zero? }
121
+ unless other_tiers.empty?
122
+ counts = other_tiers.group_by { |t| t }.transform_values(&:size)
123
+ debug_log("Non-standard tiers present (excluding Backer/Sponsor): #{counts}")
124
+ end
125
+ end
126
+ backers_hashes = Array(raw).select { |h| h["tier"].to_s.strip.casecmp("Backer").zero? }
127
+ sponsors_hashes = Array(raw).select { |h| h["tier"].to_s.strip.casecmp("Sponsor").zero? }
128
+
129
+ backers = map_hashes_to_backers(backers_hashes, index_map, force_type: "backer")
130
+ sponsors = map_hashes_to_backers(sponsors_hashes, index_map, force_type: "organization")
131
+ debug_log("Partitioned counts => Backers=#{backers.size}, Sponsors=#{sponsors.size}")
132
+ if Kettle::Dev::DEBUGGING && backers.empty? && sponsors.empty? && Array(raw).any?
133
+ debug_log("No Backer or Sponsor tiers matched among #{Array(raw).size} BACKER-role records. If tiers are empty, they will not appear in Backers/Sponsors sections.")
134
+ end
135
+
136
+ # Additional dynamic tiers (exclude Backer/Sponsor)
137
+ extra_map = {}
138
+ Array(raw).group_by { |h| h["tier"].to_s.strip }.each do |tier, members|
139
+ normalized = tier.empty? ? "Donors" : tier
140
+ next if normalized.casecmp("Backer").zero? || normalized.casecmp("Sponsor").zero?
141
+ extra_map[normalized] = map_hashes_to_backers(members, index_map)
142
+ end
143
+ debug_log("Extra tiers detected: #{extra_map.keys.sort}") unless extra_map.empty?
144
+
60
145
  backers_md = generate_markdown(backers, empty_message: "No backers yet. Be the first!", default_name: "Backer")
61
- updated = replace_between_tags(readme, b_start, b_end, backers_md)
146
+ sponsors_md_base = generate_markdown(sponsors, empty_message: "No sponsors yet. Be the first!", default_name: "Sponsor")
147
+
148
+ extra_tiers_md = generate_extra_tiers_markdown(extra_map)
149
+ sponsors_md = if extra_tiers_md.empty?
150
+ sponsors_md_base
151
+ else
152
+ [sponsors_md_base, "", extra_tiers_md].join("\n")
153
+ end
154
+
155
+ # Update backers section
156
+ # If the identities in the existing block match the identities derived from current data,
157
+ # treat as no-change to avoid rewriting formatting (e.g., Markdown -> HTML OC anchors).
158
+ semantically_same_backers = semantically_same_section?(prev_backer_identities, backers)
159
+ updated = if semantically_same_backers
160
+ :no_change
161
+ else
162
+ replace_between_tags(readme, b_start, b_end, backers_md)
163
+ end
62
164
  case updated
63
165
  when :not_found
166
+ debug_log("Backers tag block not found; skipping backers section update")
64
167
  updated_readme = readme
65
168
  backers_changed = false
66
169
  new_backers = []
67
170
  when :no_change
171
+ debug_log("Backers section unchanged (identities match or generated markdown matches existing block)")
68
172
  updated_readme = readme
69
173
  backers_changed = false
70
174
  new_backers = []
@@ -72,19 +176,35 @@ module Kettle
72
176
  updated_readme = updated
73
177
  backers_changed = true
74
178
  new_backers = compute_new_members(prev_backer_identities, backers)
179
+ debug_log("Backers section updated; new_backers=#{new_backers.size}")
75
180
  end
76
181
 
77
- # Sponsors (organizations)
78
- sponsors = fetch_members("sponsors.json")
79
- sponsors_md = generate_markdown(sponsors, empty_message: "No sponsors yet. Be the first!", default_name: "Sponsor")
182
+ # Update sponsors section (with extra tiers appended when present)
80
183
  s_start, s_end = detect_sponsor_tags(updated_readme)
81
- updated2 = replace_between_tags(updated_readme, s_start, s_end, sponsors_md)
184
+ # If there is no sponsors section but there is a backers section, append extra tiers to backers instead.
185
+ if s_start == :not_found && !extra_tiers_md.empty? && b_start != :not_found
186
+ debug_log("Sponsors tags not found; appending extra tiers under Backers section")
187
+ backers_md_with_extra = [backers_md, "", extra_tiers_md].join("\n")
188
+ updated = replace_between_tags(updated_readme, b_start, b_end, backers_md_with_extra)
189
+ updated_readme = updated unless updated == :no_change || updated == :not_found
190
+ end
191
+
192
+ # Sponsors: apply the same semantic no-change rule
193
+ prev_s_ids = extract_section_identities(updated_readme, s_start, s_end)
194
+ semantically_same_sponsors = semantically_same_section?(prev_s_ids, sponsors)
195
+ updated2 = if semantically_same_sponsors
196
+ :no_change
197
+ else
198
+ replace_between_tags(updated_readme, s_start, s_end, sponsors_md)
199
+ end
82
200
  case updated2
83
201
  when :not_found
202
+ debug_log("Sponsors tag block not found; skipping sponsors section update")
84
203
  sponsors_changed = false
85
204
  final = updated_readme
86
205
  new_sponsors = []
87
206
  when :no_change
207
+ debug_log("Sponsors section unchanged (identities match or generated markdown matches existing block)")
88
208
  sponsors_changed = false
89
209
  final = updated_readme
90
210
  new_sponsors = []
@@ -92,6 +212,7 @@ module Kettle
92
212
  sponsors_changed = true
93
213
  final = updated2
94
214
  new_sponsors = compute_new_members(prev_sponsor_identities, sponsors)
215
+ debug_log("Sponsors section updated; new_sponsors=#{new_sponsors.size}")
95
216
  end
96
217
 
97
218
  if !backers_changed && !sponsors_changed
@@ -99,9 +220,11 @@ module Kettle
99
220
  ts = tag_strings
100
221
  warn("No recognized Open Collective tags found in #{@readme_path}. Expected one or more of: " \
101
222
  "#{ts[:generic_start]}/#{ts[:generic_end]}, #{ts[:individuals_start]}/#{ts[:individuals_end]}, #{ts[:orgs_start]}/#{ts[:orgs_end]}.")
223
+ debug_log("Missing tags: looked for #{ts}")
102
224
  # Do not exit the process during tests or library use; just return.
103
225
  return
104
226
  end
227
+ debug_log("No changes detected after processing; Backers=#{backers.size}, Sponsors=#{sponsors.size}, ExtraTiers=#{extra_map.keys.size}")
105
228
  puts "No changes to backers or sponsors sections in #{@readme_path}."
106
229
  return
107
230
  end
@@ -122,9 +245,9 @@ module Kettle
122
245
  env = ENV["KETTLE_DEV_BACKER_README_OSC_TAG"].to_s
123
246
  return env unless env.strip.empty?
124
247
 
125
- if File.file?(OpenCollectiveConfig.yaml_path)
248
+ if File.file?(OC_YML_PATH)
126
249
  begin
127
- yml = YAML.safe_load(File.read(OpenCollectiveConfig.yaml_path))
250
+ yml = YAML.safe_load(File.read(OC_YML_PATH))
128
251
  if yml.is_a?(Hash)
129
252
  from_yml = yml["readme-osc-tag"] || yml[:"readme-osc-tag"]
130
253
  from_yml = from_yml.to_s if from_yml
@@ -150,11 +273,13 @@ module Kettle
150
273
  end
151
274
 
152
275
  def resolve_handle
153
- OpenCollectiveConfig.handle(required: true)
276
+ OpenCollectiveConfig.handle(required: true, root: Dir.pwd)
154
277
  end
155
278
 
156
- def fetch_members(path)
157
- url = URI("https://opencollective.com/#{@handle}/#{path}")
279
+ def fetch_all_backers_raw
280
+ api_path = "members/all.json"
281
+ url = URI("https://opencollective.com/#{@handle}/#{api_path}")
282
+ debug_log("GET #{url}")
158
283
  response = Net::HTTP.start(url.host, url.port, use_ssl: url.scheme == "https") do |conn|
159
284
  conn.read_timeout = 10
160
285
  conn.open_timeout = 5
@@ -162,36 +287,169 @@ module Kettle
162
287
  req["User-Agent"] = "kettle-dev/README-backers"
163
288
  conn.request(req)
164
289
  end
165
- return [] unless response.is_a?(Net::HTTPSuccess)
290
+ unless response.is_a?(Net::HTTPSuccess)
291
+ body_len = (response.respond_to?(:body) && response.body) ? response.body.bytesize : 0
292
+ code = response.respond_to?(:code) ? response.code : response.class.name
293
+ warn("OpenCollective API non-success for #{api_path}: status=#{code}, body_len=#{body_len}")
294
+ debug_log("Response body (truncated 500 bytes): #{response.body.to_s[0, 500]}") if Kettle::Dev::DEBUGGING && body_len.to_i > 0
295
+ return []
296
+ end
166
297
 
167
298
  parsed = JSON.parse(response.body)
168
- Array(parsed).map do |h|
299
+ all = Array(parsed)
300
+ filtered = all.select { |h| h["role"].to_s.upcase == Backer::ROLE }
301
+ debug_log("Parsed #{all.size} records; filtered BACKER => #{filtered.size}")
302
+ filtered
303
+ rescue JSON::ParserError => e
304
+ warn("Error parsing #{api_path} JSON: #{e.message}")
305
+ debug_log("Body that failed to parse (truncated 500): #{response&.body.to_s[0, 500]}")
306
+ []
307
+ rescue StandardError => e
308
+ warn("Error fetching #{api_path}: #{e.class}: #{e.message}")
309
+ debug_log(e.backtrace.join("\n"))
310
+ []
311
+ end
312
+
313
+ # Build a deterministic OpenCollective index map used to construct avatar/website URLs.
314
+ # Rules:
315
+ # - Iterate through the raw BACKER-role array in order received from OC.
316
+ # - Maintain two independent counters: one for "backer" (users) and one for "organization".
317
+ # - Derive a stable identity key for each member by preferring profile URL, then website URL, then name; all downcased.
318
+ # - Assign the current counter as the index for that type and increment the counter.
319
+ # - Return a Hash mapping the identity key to { type: "backer"|"organization", index: Integer }.
320
+ # This lets generate_markdown output links like:
321
+ # https://opencollective.com/<handle>/<type>/<index>/website and avatar.svg
322
+ def build_oc_index_map(hashes)
323
+ counts = {"backer" => 0, "organization" => 0}
324
+ map = {}
325
+ Array(hashes).each do |h|
326
+ type = (h["type"].to_s.upcase == "ORGANIZATION") ? "organization" : "backer"
327
+ key = if h["profile"].to_s.strip != ""
328
+ h["profile"].to_s.strip.downcase
329
+ elsif h["website"].to_s.strip != ""
330
+ h["website"].to_s.strip.downcase
331
+ else
332
+ h["name"].to_s.strip.downcase
333
+ end
334
+ idx = counts[type]
335
+ counts[type] = idx + 1
336
+ map[key] = {type: type, index: idx}
337
+ end
338
+ # Helpful debug summary so users can see which index maps to which backer.
339
+ if Kettle::Dev::DEBUGGING
340
+ samples = map.first(5).map { |k, v| "#{v[:type]}##{v[:index]} => #{k}" }
341
+ debug_log("Built OC index map: backer_count=#{counts["backer"]}, organization_count=#{counts["organization"]}; sample=#{samples}")
342
+ end
343
+ map
344
+ end
345
+
346
+ def map_hashes_to_backers(hashes, index_map = nil, force_type: nil)
347
+ forced_counter = 0
348
+ Array(hashes).map do |h|
349
+ key = if h["profile"].to_s.strip != ""
350
+ h["profile"].to_s.strip.downcase
351
+ elsif h["website"].to_s.strip != ""
352
+ h["website"].to_s.strip.downcase
353
+ else
354
+ h["name"].to_s.strip.downcase
355
+ end
356
+ oc = index_map ? index_map[key] : nil
357
+ # Determine oc_type/index with optional forced type override (used for sections)
358
+ oc_type = nil
359
+ oc_index = nil
360
+ if force_type
361
+ if oc && oc[:type] == force_type && !oc[:index].nil?
362
+ oc_type = oc[:type]
363
+ oc_index = oc[:index]
364
+ else
365
+ oc_type = force_type
366
+ oc_index = forced_counter
367
+ forced_counter += 1
368
+ end
369
+ else
370
+ oc_type = oc ? oc[:type] : nil
371
+ oc_index = oc ? oc[:index] : nil
372
+ end
169
373
  Backer.new(
170
374
  name: h["name"],
171
- image: (h["image"].to_s.strip.empty? ? nil : h["image"]),
375
+ image: begin
376
+ # Prefer OpenCollective's "avatar" key; fallback to legacy "image"
377
+ img = h["avatar"]
378
+ img = h["image"] if img.to_s.strip.empty?
379
+ img.to_s.strip.empty? ? nil : img
380
+ end,
172
381
  website: (h["website"].to_s.strip.empty? ? nil : h["website"]),
173
382
  profile: (h["profile"].to_s.strip.empty? ? nil : h["profile"]),
383
+ oc_type: oc_type,
384
+ oc_index: oc_index,
174
385
  )
175
386
  end
176
- rescue JSON::ParserError => e
177
- warn("Error parsing #{path} JSON: #{e.message}")
178
- []
179
- rescue StandardError => e
180
- warn("Error fetching #{path}: #{e.class}: #{e.message}")
181
- []
182
387
  end
183
388
 
184
389
  def generate_markdown(members, empty_message:, default_name:)
185
390
  return empty_message if members.nil? || members.empty?
186
391
 
187
392
  members.map do |m|
188
- image_url = m.image || DEFAULT_AVATAR
189
- link = m.website || m.profile || "#"
190
- name = (m.name && !m.name.strip.empty?) ? m.name : default_name
191
- "[![#{escape_text(name)}](#{image_url})](#{link})"
393
+ # Prefer deterministic OpenCollective avatar/link form when index is available
394
+ if m.oc_type && !m.oc_type.to_s.strip.empty? && !m.oc_index.nil?
395
+ type = m.oc_type
396
+ idx = m.oc_index
397
+ href = "https://opencollective.com/#{@handle}/#{type}/#{idx}/website"
398
+ img = "https://opencollective.com/#{@handle}/#{type}/#{idx}/avatar.svg"
399
+ %(<a href="#{href}" target="_blank"><img src="#{img}"></a>)
400
+ else
401
+ # Fallback to prior Markdown behavior
402
+ image_url = (m.image && !m.image.to_s.strip.empty?) ? m.image : nil
403
+ primary_link = (m.website && !m.website.to_s.strip.empty?) ? m.website : nil
404
+ fallback_link = (m.profile && !m.profile.to_s.strip.empty?) ? m.profile : nil
405
+ link = primary_link || fallback_link || "#"
406
+ name = (m.name && !m.name.strip.empty?) ? m.name : default_name
407
+ if image_url
408
+ "[![#{escape_text(name)}](#{image_url})](#{link})"
409
+ else
410
+ "[#{escape_text(name)}](#{link})"
411
+ end
412
+ end
192
413
  end.join(" ")
193
414
  end
194
415
 
416
+ # Build markdown for any additional tiers beyond Backer/Sponsor.
417
+ # Accepts a Hash of { tier_name => [Backer, ...] }.
418
+ # Returns an empty string when there are no extra tiers.
419
+ def generate_extra_tiers_markdown(extra_map)
420
+ return "" if extra_map.nil? || extra_map.empty?
421
+
422
+ blocks = []
423
+ extra_map.keys.sort.each do |tier|
424
+ members = extra_map[tier]
425
+ next if members.nil? || members.empty?
426
+ # For extra tiers, render using plain Markdown links to satisfy specs
427
+ # that expect either Markdown or a bare <a> tag (without nested <img>).
428
+ members_plain = Array(members).map do |m|
429
+ Backer.new(
430
+ name: m.name,
431
+ image: m.image,
432
+ website: m.website,
433
+ profile: m.profile,
434
+ )
435
+ end
436
+ # Build a single, well-formed block per tier with deterministic spacing:
437
+ # - Header
438
+ # - One empty line
439
+ # - Links line
440
+ block = [
441
+ "### Open Collective for #{tier}",
442
+ "",
443
+ generate_markdown(members_plain, empty_message: "", default_name: tier),
444
+ ].join("\n")
445
+ blocks << block
446
+ end
447
+ # Separate multiple tiers with a single blank line between blocks.
448
+ # The caller (replace_between_tags) will append one trailing newline before the end tag,
449
+ # yielding exactly two newlines after the links line within the section.
450
+ blocks.join("\n\n")
451
+ end
452
+
195
453
  def replace_between_tags(content, start_tag, end_tag, new_content)
196
454
  return :not_found if start_tag == :not_found || end_tag == :not_found
197
455
 
@@ -201,11 +459,14 @@ module Kettle
201
459
 
202
460
  before = content[0..start_index + start_tag.length - 1]
203
461
  after = content[end_index..-1]
204
- replacement = "#{start_tag}\n#{new_content}\n#{end_tag}"
205
462
  current_block = content[start_index..end_index + end_tag.length - 1]
206
- return :no_change if current_block == replacement
463
+ # Treat as unchanged if the block matches either single or double newline spacing
464
+ unchanged_a = "#{start_tag}\n#{new_content}\n#{end_tag}"
465
+ unchanged_b = "#{start_tag}\n#{new_content}\n\n#{end_tag}"
466
+ return :no_change if current_block == unchanged_a || current_block == unchanged_b
207
467
 
208
468
  trailing = after[end_tag.length..-1] || ""
469
+ # Use a single blank line between content and end tag for normalized output
209
470
  "#{before}\n#{new_content}\n#{end_tag}#{trailing}"
210
471
  end
211
472
 
@@ -238,14 +499,28 @@ module Kettle
238
499
 
239
500
  block = content[(start_index + start_tag.length)...end_index]
240
501
  identities = Set.new
502
+ # 1) Image-style link wrappers: [![ALT](IMG)](HREF)
241
503
  block.to_s.scan(/\[!\[[^\]]*\]\([^\)]*\)\]\(([^\)]+)\)/) do |m|
242
504
  href = (m[0] || "").strip
243
505
  identities << href.downcase unless href.empty?
244
506
  end
507
+ # 2) Capture ALT text from image-style wrappers for name identity
245
508
  block.to_s.scan(/\[!\[([^\]]*)\]\([^\)]*\)\]\([^\)]*\)/) do |m|
246
509
  alt = (m[0] || "").strip
247
510
  identities << alt.downcase unless alt.empty?
248
511
  end
512
+ # 3) Plain markdown links: [TEXT](HREF)
513
+ block.to_s.scan(/\[([^!][^\]]*)\]\(([^\)]+)\)/) do |m|
514
+ text = (m[0] || "").strip
515
+ href = (m[1] || "").strip
516
+ identities << href.downcase unless href.empty?
517
+ identities << text.downcase unless text.empty?
518
+ end
519
+ # 4) HTML anchors: <a href="HREF">...</a>
520
+ block.to_s.scan(/<a\s+[^>]*href=["']([^"']+)["'][^>]*>/i) do |m|
521
+ href = (m[0] || "").strip
522
+ identities << href.downcase unless href.empty?
523
+ end
249
524
  identities
250
525
  end
251
526
 
@@ -257,6 +532,71 @@ module Kettle
257
532
  end
258
533
  end
259
534
 
535
+ # Build the identity set for a list of members using the same precedence
536
+ # as compute_new_members/identity_for_member. Used to compare semantic
537
+ # equivalence of existing README sections vs new data, to avoid rewrites
538
+ # when only formatting differs.
539
+ # @param members [Array<Backer>]
540
+ # @return [Set<String>]
541
+ def identities_for_members(members)
542
+ set = Set.new
543
+ Array(members).each do |m|
544
+ ids = []
545
+ # Include deterministic OpenCollective href identity when available
546
+ if m.oc_type && !m.oc_type.to_s.strip.empty? && !m.oc_index.nil?
547
+ ids << "https://opencollective.com/#{@handle}/#{m.oc_type}/#{m.oc_index}/website"
548
+ end
549
+ # Also include the standard identity used elsewhere (profile/website/name)
550
+ ids << identity_for_member(m)
551
+ ids.compact.each do |id|
552
+ norm = id.to_s.strip.downcase
553
+ set << norm unless norm.empty?
554
+ end
555
+ end
556
+ set
557
+ end
558
+
559
+ # Determine if the new identity set is semantically the same as the previous
560
+ # block found in the README. We treat it as the same when all new identities
561
+ # are already present in the previous set (subset), allowing the previous set
562
+ # to contain additional identities such as plain-text names extracted from
563
+ # Markdown ALT text. This avoids unnecessary rewrites when only formatting
564
+ # changes (e.g., Markdown -> HTML OC anchors) but the underlying members are
565
+ # unchanged.
566
+ # @param previous [Set<String>]
567
+ # @param new_set [Set<String>]
568
+ # @return [Boolean]
569
+ def identities_semantically_same?(previous, new_set)
570
+ return false if new_set.nil? || new_set.empty?
571
+ return false if previous.nil? || previous.empty?
572
+ new_set.all? { |id| previous.include?(id) }
573
+ end
574
+
575
+ # Determine semantic sameness by ensuring each member has at least one
576
+ # identity present in the previous README identities set. This allows
577
+ # different formatting (OC anchors vs Markdown website/profile links)
578
+ # without rewriting when the underlying members are the same.
579
+ # @param previous [Set<String>]
580
+ # @param members [Array<Backer>]
581
+ # @return [Boolean]
582
+ def semantically_same_section?(previous, members)
583
+ return false if previous.nil? || previous.empty?
584
+ Array(members).all? do |m|
585
+ candidates = []
586
+ if m.oc_type && !m.oc_type.to_s.strip.empty? && !m.oc_index.nil?
587
+ candidates << "https://opencollective.com/#{@handle}/#{m.oc_type}/#{m.oc_index}/website"
588
+ end
589
+ candidates << m.profile
590
+ candidates << m.website
591
+ candidates << m.name
592
+ candidates.compact!
593
+ candidates.any? { |id|
594
+ s = id.to_s.strip.downcase
595
+ !s.empty? && previous.include?(s)
596
+ }
597
+ end
598
+ end
599
+
260
600
  def identity_for_member(m)
261
601
  if m.profile && !m.profile.strip.empty?
262
602
  m.profile.strip.downcase
@@ -20,14 +20,32 @@ module Kettle
20
20
  def run_cmd!(cmd)
21
21
  # For Bundler-invoked build/release, explicitly prefix SKIP_GEM_SIGNING so
22
22
  # the signing step is skipped even when Bundler scrubs ENV.
23
+ # Always do this on CI to avoid interactive prompts; locally only when explicitly requested.
23
24
  if ENV["SKIP_GEM_SIGNING"] && cmd =~ /\Abundle(\s+exec)?\s+rake\s+(build|release)\b/
24
25
  cmd = "SKIP_GEM_SIGNING=true #{cmd}"
25
26
  end
26
27
  puts "$ #{cmd}"
27
28
  # Pass a plain Hash for the environment to satisfy tests and avoid ENV object oddities
28
29
  env_hash = ENV.respond_to?(:to_hash) ? ENV.to_hash : ENV.to_h
29
- success = system(env_hash, cmd)
30
- abort("Command failed: #{cmd}") unless success
30
+
31
+ # Capture output so we can surface clear diagnostics on failure
32
+ stdout_str, stderr_str, status = Open3.capture3(env_hash, cmd)
33
+
34
+ # Echo command output to match prior behavior
35
+ $stdout.print(stdout_str) unless stdout_str.nil? || stdout_str.empty?
36
+ $stderr.print(stderr_str) unless stderr_str.nil? || stderr_str.empty?
37
+
38
+ unless status.success?
39
+ exit_code = status.exitstatus
40
+ # Keep the original prefix to avoid breaking any tooling/tests that grep for it,
41
+ # but add the exit status and a brief diagnostic tail from stderr.
42
+ diag = ""
43
+ unless stderr_str.to_s.empty?
44
+ tail = stderr_str.lines.last(20).join
45
+ diag = "\n--- STDERR (last 20 lines) ---\n#{tail}".rstrip
46
+ end
47
+ abort("Command failed: #{cmd} (exit #{exit_code})#{diag}")
48
+ end
31
49
  end
32
50
  end
33
51
 
@@ -260,7 +260,7 @@ module Kettle
260
260
 
261
261
  # We need to sleep a bit here to ensure the terminal is ready for both
262
262
  # input and writing status updates to each workflow's line
263
- sleep(0.2) unless Kettle::Dev::IS_CI
263
+ sleep(0.2)
264
264
 
265
265
  selected = nil
266
266
  # Create input thread always so specs that assert its cleanup/exception behavior can exercise it,
@@ -6,7 +6,7 @@ module Kettle
6
6
  module Version
7
7
  # The gem version.
8
8
  # @return [String]
9
- VERSION = "1.1.24"
9
+ VERSION = "1.1.26"
10
10
 
11
11
  module_function
12
12
 
@@ -10,6 +10,7 @@ module Kettle
10
10
  ) -> void
11
11
 
12
12
  def run!: () -> void
13
+ def validate: () -> void
13
14
 
14
15
  # Selected public helpers (kept minimal)
15
16
  def readme_osc_tag: () -> String
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: 1.1.24
4
+ version: 1.1.26
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -404,10 +404,10 @@ licenses:
404
404
  - MIT
405
405
  metadata:
406
406
  homepage_uri: https://kettle-dev.galtzo.com/
407
- source_code_uri: https://github.com/kettle-rb/kettle-dev/tree/v1.1.24
408
- changelog_uri: https://github.com/kettle-rb/kettle-dev/blob/v1.1.24/CHANGELOG.md
407
+ source_code_uri: https://github.com/kettle-rb/kettle-dev/tree/v1.1.26
408
+ changelog_uri: https://github.com/kettle-rb/kettle-dev/blob/v1.1.26/CHANGELOG.md
409
409
  bug_tracker_uri: https://github.com/kettle-rb/kettle-dev/issues
410
- documentation_uri: https://www.rubydoc.info/gems/kettle-dev/1.1.24
410
+ documentation_uri: https://www.rubydoc.info/gems/kettle-dev/1.1.26
411
411
  funding_uri: https://github.com/sponsors/pboling
412
412
  wiki_uri: https://github.com/kettle-rb/kettle-dev/wiki
413
413
  news_uri: https://www.railsbling.com/tags/kettle-dev
metadata.gz.sig CHANGED
@@ -1,3 +1,2 @@
1
- \[���}��In������qȲ,��n�� ��?V��bx+���?《��XM3~��!��h!�Ӷ�z���E,_��I~�7H�'��=�ٵ���Pƕ�V�t�,�hK�8�(�U�<rt�s�!YQ�4��n�\�E�2L��h��F\E�M��������! 8v���r}d#��J|a���yr
2
- � ���m��I��}ETW����p���G� ��KH�(���pS~�k���eS#��5_1;��I�I�������tFi��pB`�/��-�Jf�[��<��r��a��
3
- "�@+Ks�eM�޿AҾ�,��_e9��689�K&?�3 �t�Q��m���Q�?*�F�6d���X����*p&��)�A�,�绒�$�
1
+ x�!���]�����G4u��Q
2
+ ��6�RM)J��_��-:�C���4Lz��