kettle-dev 1.0.0 → 1.0.1

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: d64f3a7f29e3470e85bfa3ad0ba278f7f591c618ced02911e6bdf00f804ee128
4
- data.tar.gz: 90e9d7630f0115f908efff5e5ad359ff787cb80660a44fd5b6ba63a01839fb6b
3
+ metadata.gz: 78bcc9bdf8d357f03e49a189ec85cdde3aae0589085735fe90f7e1843d0bf8dc
4
+ data.tar.gz: e8210c52d958b4e9c6d154cb18c7098a74994c20c9cff74038f52c2df1179b72
5
5
  SHA512:
6
- metadata.gz: b9323a16c2cc9236888a268907031508b9b74b99feb5d149233604809e855bda0e34a03543d7cd683affe0e9139b993a31f7f746b226a9a9cec06987045d99b3
7
- data.tar.gz: 48f6ef3c7424930a5840f71b2f9dcf86df2e6bb1a9b341bc29dfc15941ef6576bc2fad093b34e6d210792b70b6f5f4fbe180d86eaee21e7bd7f82f0e29396b8a
6
+ metadata.gz: d798f16b37df5bd1e96e94fb45c964e5ee8913724af313c04936689feb9a52b1096aba043edb5a3b1444cfeeb81e96a3e9390ce0d331d1782dd735d11a185ab1
7
+ data.tar.gz: 35b9bb8a37bdd71891f47c340af180999cc9658924c63c92267a4f6d6581cefbdc8e7b7c173fdc61feddb7dbc2f908f1bc7b106a09ae1470a87fb7629096ca2d
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -12,6 +12,18 @@ and this project adheres to [Semantic Versioning v2](https://semver.org/spec/v2.
12
12
  ### Fixed
13
13
  ### Security
14
14
 
15
+ ## [1.0.1] - 2025-08-24
16
+ - TAG: [v1.0.1][1.0.1t]
17
+ - COVERAGE: 100.00% -- 98/98 lines in 7 files
18
+ - BRANCH COVERAGE: 100.00% -- 30/30 branches in 7 files
19
+ - 94.59% documented
20
+ ### Added
21
+ - These were documented but not yet released:
22
+ - `kettle-release` ruby script for safely, securely, releasing a gem.
23
+ - This may move to its own gem in the future.
24
+ - `kettle-readme-backers` ruby script for integrating Open Source Collective backers into a README.md file.
25
+ - This may move to its own gem in the future.
26
+
15
27
  ## [1.0.0] - 2025-08-24
16
28
  - TAG: [v1.0.0][1.0.0t]
17
29
  - COVERAGE: 100.00% -- 98/98 lines in 7 files
@@ -37,11 +49,9 @@ and this project adheres to [Semantic Versioning v2](https://semver.org/spec/v2.
37
49
  - `ci:act` rake task CLI menu / scoreboard for a project's GHA workflows
38
50
  - Selecting will run the selected workflow via `act`
39
51
  - This may move to its own gem in the future.
40
- - `kettle-release` ruby script for safely, securely, releasing a gem.
41
- - This may move to its own gem in the future.
42
- - `kettle-readme-backers` ruby script for integrating Open Source Collective backers into a README.md file.
43
- - This may move to its own gem in the future.
44
52
 
45
- [Unreleased]: https://gitlab.com/kettle-rb/kettle-dev/-/compare/v1.0.0...HEAD
53
+ [Unreleased]: https://gitlab.com/kettle-rb/kettle-dev/-/compare/v1.0.1...HEAD
54
+ [1.0.1]: https://gitlab.com/kettle-rb/kettle-dev/-/compare/v1.0.0...v1.0.1
55
+ [1.0.1t]: https://gitlab.com/kettle-rb/kettle-dev/-/tags/v1.0.1
46
56
  [1.0.0]: https://gitlab.com/kettle-rb/kettle-dev/-/compare/a427c302df09cfe4253a7c8d400333f9a4c1a208...v1.0.0
47
57
  [1.0.0t]: https://gitlab.com/kettle-rb/kettle-dev/-/tags/v1.0.0
@@ -0,0 +1 @@
1
+ 5b92c8a76f54954791e4154bb22594dbc9dd4f0e06d9d4c5db15a32aa2718ede
@@ -0,0 +1 @@
1
+ 5cac18505a8f780d3897f1d900a662c896537056441b1a8178531355a4b10198bee0bdc62216ab2036b640adada34911bc30b813f715a14a464d797718d7a065
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env ruby
2
+ # vim: set syntax=ruby
3
+
4
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
5
+
6
+ require "rubygems"
7
+ require "bundler/setup"
8
+
9
+ # Standard library
10
+ require "erb"
11
+
12
+ # ENV variable control (set in .envrc, or .env.local)
13
+ # BRANCH_RULE_TYPE = jira, or another type of branch rule validation, or false to disable
14
+ # FOOTER_APPEND = true/false append commit message footer
15
+ BRANCH_RULES = {
16
+ "jira" => /^(?<story_type>(hotfix)|(bug)|(feature)|(candy))\/(?<story_id>\d{8,})-.+\Z/,
17
+ }
18
+ BRANCH_RULE_TYPE = (validate = ENV.fetch("GIT_HOOK_BRANCH_VALIDATE", "false")) && !validate.casecmp?("false") && validate
19
+
20
+ # Called the "JIRA pattern", because of traditional use with JIRA.
21
+ if (branch_rule = BRANCH_RULES[BRANCH_RULE_TYPE])
22
+ # branches should be named like:
23
+ # <story_type>/<story_id>-explosion-in-the-fudge-factory-spec-suite-fix
24
+ # where story type is one of "hotfix", "bug", "feature", "candy"
25
+ #
26
+ branch = %x(git branch 2> /dev/null | grep -e ^* | awk '{print $2}')
27
+ match_data = branch.match(branch_rule)
28
+ # NOTE: `match` will return nil if match fails, otherwise an instance of MatchData.
29
+ # If not nil then we are assured matches for both regex capture groups
30
+ # match_data_or_nil[:story_type] will be one of "hotfix", "bug", "feature", "candy"
31
+ # match_data_or_nil[:story_id] will be a numeric string
32
+
33
+ if !match_data.nil?
34
+ commit_msg = File.read(ARGV[0])
35
+ unless commit_msg.include?(match_data[:story_id])
36
+ commit_msg = <<~EOS
37
+ #{commit_msg.strip}
38
+ [#{match_data[:story_type]}][#{match_data[:story_id]}]
39
+ EOS
40
+ File.open(ARGV[0], "w") do |file|
41
+ file.print(commit_msg)
42
+ end
43
+ end
44
+ end
45
+ else
46
+ # puts "No branch rule configured (set GIT_HOOK_BRANCH_VALIDATE=jira to enforce rules for jira style branch names)"
47
+ end
48
+
49
+ class GitCommitFooter
50
+ # Prefer project-local .git-hooks (repo root), then fallback to global ~/.git-hooks
51
+ NAME_ASSIGNMENT_REGEX = /\bname\s*=\s*(["'])([^"']+)\1/.freeze
52
+ FOOTER_APPEND = ENV.fetch("GIT_HOOK_FOOTER_APPEND", "false").casecmp("true").zero?
53
+ SENTINEL = ENV["GIT_HOOK_FOOTER_SENTINEL"] # No default to avoid accidental duplicate commit of a footer via ammended commits
54
+ raise "Set GIT_HOOK_FOOTER_SENTINEL=<footer sentinel> in .env.local (e.g., '⚡️ A message from a fellow meat-based-AI ⚡️')" if FOOTER_APPEND && SENTINEL.nil? || SENTINEL.empty?
55
+
56
+ class << self
57
+ def git_toplevel
58
+ toplevel = nil
59
+ begin
60
+ # 'git rev-parse --show-toplevel' returns the repo root when run anywhere inside the repo
61
+ out = %x(git rev-parse --show-toplevel 2>/dev/null)
62
+ toplevel = out.strip unless out.nil? || out.empty?
63
+ rescue StandardError
64
+ # ignore
65
+ end
66
+ toplevel
67
+ end
68
+
69
+ def local_hooks_dir
70
+ top = git_toplevel
71
+ return unless top && !top.empty?
72
+ File.join(top, ".git-hooks")
73
+ end
74
+
75
+ def global_hooks_dir
76
+ File.join(ENV["HOME"], ".git-hooks")
77
+ end
78
+
79
+ def hooks_path_for(filename)
80
+ local_dir = local_hooks_dir
81
+ if local_dir
82
+ local_path = File.join(local_dir, filename)
83
+ return local_path if File.file?(local_path)
84
+ end
85
+ File.join(global_hooks_dir, filename)
86
+ end
87
+
88
+ def commit_goalie_path
89
+ hooks_path_for("commit-subjects-goalie.txt")
90
+ end
91
+
92
+ # Determine whether the commit subject allows footer append, based on optional goalie file
93
+ # ~/.git-hooks/commit-subjects-goalie.txt
94
+ # - If present, only allow appending when the first line of the commit message starts with one of the non-commented prefixes
95
+ # - If absent, disallow footer
96
+ def goalie_allows_footer?(subject_line)
97
+ goalie_path = commit_goalie_path
98
+ return false unless File.file?(goalie_path)
99
+
100
+ prefixes = File.read(goalie_path).lines.map { |l| l.strip }.reject { |l| l.empty? || l.start_with?("#") }
101
+ # If the file exists but has no usable lines, treat as deny-all per goalie intent
102
+ return false if prefixes.empty?
103
+
104
+ subj = subject_line.to_s.strip
105
+ prefixes.any? { |prefix| subj.start_with?(prefix) }
106
+ end
107
+
108
+ def render(*argv)
109
+ commit_msg = File.read(argv[0])
110
+ subject_line = commit_msg.lines.first.to_s
111
+ if GitCommitFooter::FOOTER_APPEND && goalie_allows_footer?(subject_line)
112
+ if commit_msg.include?(GitCommitFooter::SENTINEL)
113
+ # This is a commit message that has already been appended
114
+ # This will happen if the commit message is edited and re-committed
115
+ # puts "FOOTER_APPEND is true, skipping footer append"
116
+ exit(0)
117
+ else
118
+ footer_binding = GitCommitFooter.new
119
+ # Append footer to the commit message
120
+ File.open(argv[0], "w") do |file|
121
+ file.print(commit_msg)
122
+ file.print("\n")
123
+ file.print(footer_binding.render)
124
+ end
125
+ end
126
+ else
127
+ # Skipping footer append (either FOOTER_APPEND is false, or goalie did not allow it)
128
+ end
129
+ end
130
+ end
131
+
132
+ def initialize
133
+ @pwd = Dir.pwd
134
+ @gemspecs = Dir["*.gemspec"]
135
+ @spec = @gemspecs.first
136
+ @gemspec_path = File.expand_path(@spec, @pwd)
137
+ @gem_name = parse_gemspec_name || derive_gem_name
138
+ end
139
+
140
+ # Render ERB with binding variables
141
+ def render
142
+ ERB.new(template).result(binding)
143
+ end
144
+
145
+ private
146
+
147
+ # Lightweight parse for gem name to avoid full Gem::Specification load
148
+ def parse_gemspec_name
149
+ begin
150
+ content = File.read(@gemspec_path)
151
+ # Look for name assignment patterns like:
152
+ # spec.name = "my_gem" OR Gem::Specification.new do |spec|; spec.name = 'my_gem'
153
+ @name_index = content =~ NAME_ASSIGNMENT_REGEX
154
+ if @name_index
155
+ return $2
156
+ end
157
+ rescue StandardError
158
+ # fall through
159
+ end
160
+ nil
161
+ end
162
+
163
+ # No-parse derivation of gem name, when parsing gemspec fails
164
+ def derive_gem_name
165
+ File.basename(@gemspec_path, ".*") if @gemspec_path
166
+ end
167
+
168
+ # Example
169
+ #
170
+ # ⚡️ A message from a fellow meat-based-AI ⚡️
171
+ # I ❤️ working on <%= @gem_name %>.
172
+ #
173
+ # The first line is the footer sentinel (which does appear in the commit).
174
+ # The second line, and any additional, is the main body of the footer.
175
+ #
176
+ # The sentinel must be set in an ENV variable (e.g., in your .env.local file):
177
+ #
178
+ # export GIT_HOOK_FOOTER_SENTINEL="⚡️ A message from a fellow meat-based-AI ⚡️"
179
+ #
180
+ def template
181
+ File.read(self.class.hooks_path_for("footer-template.erb.txt"))
182
+ end
183
+ end
184
+
185
+ GitCommitFooter.render(*ARGV)
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Updates README.md backers and sponsors sections using data from Open Collective
5
+ # backers.json and sponsors.json for the configured handle.
6
+ #
7
+ # Backers (individuals) section markers supported (first match wins):
8
+ # <!-- OPENCOLLECTIVE:START --> ... <!-- OPENCOLLECTIVE:END -->
9
+ # <!-- OPENCOLLECTIVE-INDIVIDUALS:START --> ... <!-- OPENCOLLECTIVE-INDIVIDUALS:END -->
10
+ # Sponsors (organizations) section markers:
11
+ # <!-- OPENCOLLECTIVE-ORGANIZATIONS:START --> ... <!-- OPENCOLLECTIVE-ORGANIZATIONS:END -->
12
+ #
13
+ # Handle resolution order:
14
+ # 1. ENV["OPENCOLLECTIVE_HANDLE"] if present
15
+ # 2. .opencollective.yml's `collective:` key in project root if present
16
+ # 3. Abort with error
17
+ #
18
+ # Usage:
19
+ # OPENCOLLECTIVE_HANDLE=kettle-rb exe/kettle-readme-backers
20
+ # # or ensure .opencollective.yml exists with collective: "kettle-rb"
21
+
22
+ require "rubygems"
23
+ require "bundler/setup"
24
+
25
+ require "yaml"
26
+ require "json"
27
+ require "uri"
28
+ require "net/http"
29
+ require "set"
30
+
31
+ module Kettle
32
+ module Dev
33
+ class ReadmeBackers
34
+ DEFAULT_AVATAR = "https://opencollective.com/static/images/default-avatar.png"
35
+ README_PATH = File.expand_path("../README.md", __dir__)
36
+ OC_YML_PATH = File.expand_path("../.opencollective.yml", __dir__)
37
+ README_OSC_TAG_DEFAULT = "OPENCOLLECTIVE"
38
+ COMMIT_SUBJECT_DEFAULT = "💸 Thanks 🙏 to our new backers 🎒 and subscribers 📜"
39
+
40
+ Backer = Struct.new(:name, :image, :website, :profile, keyword_init: true)
41
+
42
+ def initialize(handle: nil, readme_path: README_PATH)
43
+ @handle = handle || resolve_handle
44
+ @readme_path = readme_path
45
+ end
46
+
47
+ def run!
48
+ readme = File.read(@readme_path)
49
+
50
+ # Identify previous entries for diffing/mentions
51
+ b_start, b_end = detect_backer_tags(readme)
52
+ prev_backer_identities = extract_section_identities(readme, b_start, b_end)
53
+ s_start_prev, s_end_prev = detect_sponsor_tags(readme)
54
+ prev_sponsor_identities = extract_section_identities(readme, s_start_prev, s_end_prev)
55
+
56
+ # Backers (individuals)
57
+ backers = fetch_members("backers.json")
58
+ backers_md = generate_markdown(backers, empty_message: "No backers yet. Be the first!", default_name: "Backer")
59
+ updated = replace_between_tags(readme, b_start, b_end, backers_md)
60
+ case updated
61
+ when :not_found
62
+ # Do not exit yet; we may still update sponsors.
63
+ updated_readme = readme
64
+ backers_changed = false
65
+ new_backers = []
66
+ when :no_change
67
+ updated_readme = readme
68
+ backers_changed = false
69
+ new_backers = []
70
+ else
71
+ updated_readme = updated
72
+ backers_changed = true
73
+ new_backers = compute_new_members(prev_backer_identities, backers)
74
+ end
75
+
76
+ # Sponsors (organizations)
77
+ sponsors = fetch_members("sponsors.json")
78
+ sponsors_md = generate_markdown(sponsors, empty_message: "No sponsors yet. Be the first!", default_name: "Sponsor")
79
+ s_start, s_end = detect_sponsor_tags(updated_readme)
80
+ updated2 = replace_between_tags(updated_readme, s_start, s_end, sponsors_md)
81
+ case updated2
82
+ when :not_found
83
+ sponsors_changed = false
84
+ final = updated_readme
85
+ new_sponsors = []
86
+ when :no_change
87
+ sponsors_changed = false
88
+ final = updated_readme
89
+ new_sponsors = []
90
+ else
91
+ sponsors_changed = true
92
+ final = updated2
93
+ new_sponsors = compute_new_members(prev_sponsor_identities, sponsors)
94
+ end
95
+
96
+ if !backers_changed && !sponsors_changed
97
+ if b_start == :not_found && s_start == :not_found
98
+ ts = tag_strings
99
+ warn("No recognized Open Collective tags found in #{@readme_path}. Expected one or more of: " \
100
+ "#{ts[:generic_start]}/#{ts[:generic_end]}, #{ts[:individuals_start]}/#{ts[:individuals_end]}, #{ts[:orgs_start]}/#{ts[:orgs_end]}.")
101
+ exit(2)
102
+ end
103
+ puts "No changes to backers or sponsors sections in #{@readme_path}."
104
+ return
105
+ end
106
+
107
+ File.write(@readme_path, final)
108
+ msgs = []
109
+ msgs << "backers" if backers_changed
110
+ msgs << "sponsors" if sponsors_changed
111
+ puts "Updated #{msgs.join(" and ")} section#{{true => "s", false => ""}[msgs.size > 1]} in #{@readme_path}."
112
+
113
+ # Compose and perform commit with mentions if in a git repo
114
+ perform_git_commit(new_backers, new_sponsors) if git_repo? && (backers_changed || sponsors_changed)
115
+ end
116
+
117
+ private
118
+
119
+ def readme_osc_tag
120
+ env = ENV["KETTLE_DEV_BACKER_README_OSC_TAG"].to_s
121
+ return env unless env.strip.empty?
122
+ if File.file?(OC_YML_PATH)
123
+ begin
124
+ yml = YAML.safe_load(File.read(OC_YML_PATH))
125
+ if yml.is_a?(Hash)
126
+ from_yml = yml["readme-osc-tag"] || yml[:"readme-osc-tag"]
127
+ from_yml = from_yml.to_s if from_yml
128
+ return from_yml unless from_yml.nil? || from_yml.strip.empty?
129
+ end
130
+ rescue StandardError
131
+ # ignore yaml errors and fall back
132
+ end
133
+ end
134
+ README_OSC_TAG_DEFAULT
135
+ end
136
+
137
+ def tag_strings
138
+ base = readme_osc_tag
139
+ {
140
+ generic_start: "<!-- #{base}:START -->",
141
+ generic_end: "<!-- #{base}:END -->",
142
+ individuals_start: "<!-- #{base}-INDIVIDUALS:START -->",
143
+ individuals_end: "<!-- #{base}-INDIVIDUALS:END -->",
144
+ orgs_start: "<!-- #{base}-ORGANIZATIONS:START -->",
145
+ orgs_end: "<!-- #{base}-ORGANIZATIONS:END -->",
146
+ }
147
+ end
148
+
149
+ def resolve_handle
150
+ env = ENV["OPENCOLLECTIVE_HANDLE"]
151
+ return env unless env.nil? || env.strip.empty?
152
+ if File.file?(OC_YML_PATH)
153
+ yml = YAML.safe_load(File.read(OC_YML_PATH))
154
+ handle = yml.is_a?(Hash) ? yml["collective"] || yml[:collective] : nil
155
+ return handle.to_s unless handle.nil? || handle.to_s.strip.empty?
156
+ end
157
+ abort("ERROR: Open Collective handle not provided. Set OPENCOLLECTIVE_HANDLE or add 'collective: <handle>' to .opencollective.yml.")
158
+ end
159
+
160
+ def fetch_members(path)
161
+ url = URI("https://opencollective.com/#{@handle}/#{path}")
162
+ response = Net::HTTP.start(url.host, url.port, use_ssl: url.scheme == "https") do |conn|
163
+ conn.read_timeout = 10
164
+ conn.open_timeout = 5
165
+ req = Net::HTTP::Get.new(url)
166
+ req["User-Agent"] = "kettle-dev/README-backers"
167
+ conn.request(req)
168
+ end
169
+ return [] unless response.is_a?(Net::HTTPSuccess)
170
+ parsed = JSON.parse(response.body)
171
+ Array(parsed).map do |h|
172
+ Backer.new(
173
+ name: h["name"],
174
+ image: (h["image"].to_s.strip.empty? ? nil : h["image"]),
175
+ website: (h["website"].to_s.strip.empty? ? nil : h["website"]),
176
+ profile: (h["profile"].to_s.strip.empty? ? nil : h["profile"]),
177
+ )
178
+ end
179
+ rescue JSON::ParserError => e
180
+ warn("Error parsing #{path} JSON: #{e.message}")
181
+ []
182
+ rescue StandardError => e
183
+ warn("Error fetching #{path}: #{e.class}: #{e.message}")
184
+ []
185
+ end
186
+
187
+ def generate_markdown(members, empty_message:, default_name:)
188
+ return empty_message if members.nil? || members.empty?
189
+ members.map do |m|
190
+ image_url = m.image || DEFAULT_AVATAR
191
+ link = m.website || m.profile || "#"
192
+ name = (m.name && !m.name.strip.empty?) ? m.name : default_name
193
+ "[![#{escape_text(name)}](#{image_url})](#{link})"
194
+ end.join(" ")
195
+ end
196
+
197
+ def replace_between_tags(content, start_tag, end_tag, new_content)
198
+ return :not_found if start_tag == :not_found || end_tag == :not_found
199
+ start_index = content.index(start_tag)
200
+ end_index = content.index(end_tag)
201
+ return :not_found if start_index.nil? || end_index.nil? || end_index < start_index
202
+ before = content[0..start_index + start_tag.length - 1]
203
+ after = content[end_index..-1]
204
+ replacement = "#{start_tag}\n#{new_content}\n#{end_tag}"
205
+ current_block = content[start_index..end_index + end_tag.length - 1]
206
+ return :no_change if current_block == replacement
207
+ trailing = after[end_tag.length..-1] || ""
208
+ "#{before}\n#{new_content}\n#{end_tag}#{trailing}"
209
+ end
210
+
211
+ def detect_backer_tags(content)
212
+ ts = tag_strings
213
+ if content.include?(ts[:generic_start]) && content.include?(ts[:generic_end])
214
+ [ts[:generic_start], ts[:generic_end]]
215
+ elsif content.include?(ts[:individuals_start]) && content.include?(ts[:individuals_end])
216
+ [ts[:individuals_start], ts[:individuals_end]]
217
+ else
218
+ [:not_found, :not_found]
219
+ end
220
+ end
221
+
222
+ def detect_sponsor_tags(content)
223
+ ts = tag_strings
224
+ if content.include?(ts[:orgs_start]) && content.include?(ts[:orgs_end])
225
+ [ts[:orgs_start], ts[:orgs_end]]
226
+ else
227
+ [:not_found, :not_found]
228
+ end
229
+ end
230
+
231
+ # Extract identity tokens from the current README section between start/end tags
232
+ # Identity priority used for comparison:
233
+ # href (profile/website URL) downcased, else alt text (name) downcased
234
+ def extract_section_identities(content, start_tag, end_tag)
235
+ return Set.new unless start_tag && end_tag && start_tag != :not_found && end_tag != :not_found
236
+ start_index = content.index(start_tag)
237
+ end_index = content.index(end_tag)
238
+ return Set.new if start_index.nil? || end_index.nil? || end_index < start_index
239
+ block = content[(start_index + start_tag.length)...end_index]
240
+ identities = Set.new
241
+ # Match patterns like: [![Alt](image_url)](link_url)
242
+ block.to_s.scan(/\[!\[[^\]]*\]\([^\)]*\)\]\(([^\)]+)\)/) do |m|
243
+ href = (m[0] || "").strip
244
+ identities << href.downcase unless href.empty?
245
+ end
246
+ # Also capture alt texts in case links are missing
247
+ block.to_s.scan(/\[!\[([^\]]*)\]\([^\)]*\)\]\([^\)]*\)/) do |m|
248
+ alt = (m[0] || "").strip
249
+ identities << alt.downcase unless alt.empty?
250
+ end
251
+ identities
252
+ end
253
+
254
+ def compute_new_members(previous_identities, members)
255
+ prev = previous_identities || Set.new
256
+ members.select do |m|
257
+ id = identity_for_member(m)
258
+ !prev.include?(id)
259
+ end
260
+ end
261
+
262
+ def identity_for_member(m)
263
+ if m.profile && !m.profile.strip.empty?
264
+ m.profile.strip.downcase
265
+ elsif m.website && !m.website.strip.empty?
266
+ m.website.strip.downcase
267
+ elsif m.name && !m.name.strip.empty?
268
+ m.name.strip.downcase
269
+ else
270
+ ""
271
+ end
272
+ end
273
+
274
+ def mention_for_member(m, default_name: "Member")
275
+ handle = github_handle_from_urls(m.profile, m.website)
276
+ return "@#{handle}" if handle
277
+ name = (m.name && !m.name.strip.empty?) ? m.name.strip : default_name
278
+ name
279
+ end
280
+
281
+ def github_handle_from_urls(*urls)
282
+ urls.compact.each do |u|
283
+ begin
284
+ uri = URI.parse(u)
285
+ rescue URI::InvalidURIError
286
+ next
287
+ end
288
+ next unless uri&.host&.downcase&.end_with?("github.com")
289
+ path = (uri.path || "").sub(%r{^/}, "").sub(%r{/$}, "")
290
+ next if path.empty?
291
+ parts = path.split("/")
292
+ # github.com/sponsors/<handle> or github.com/<handle>/...
293
+ candidate = if parts[0].downcase == "sponsors" && parts[1]
294
+ parts[1]
295
+ else
296
+ parts[0]
297
+ end
298
+ candidate = candidate.gsub(%r{[^a-zA-Z0-9-]}, "")
299
+ return candidate unless candidate.empty?
300
+ end
301
+ nil
302
+ end
303
+
304
+ def perform_git_commit(new_backers, new_sponsors)
305
+ backer_mentions = new_backers.map { |m| mention_for_member(m, default_name: "Backer") }.uniq
306
+ sponsor_mentions = new_sponsors.map { |m| mention_for_member(m, default_name: "Subscriber") }.uniq
307
+ title = commit_subject
308
+ lines = [title]
309
+ lines << ""
310
+ lines << "Backers: #{backer_mentions.join(", ")}" unless backer_mentions.empty?
311
+ lines << "Subscribers: #{sponsor_mentions.join(", ")}" unless sponsor_mentions.empty?
312
+ message = lines.join("\n")
313
+ # Stage and commit README.md
314
+ system("git", "add", @readme_path)
315
+ # Only commit if README is staged/changed
316
+ if system("git", "diff", "--cached", "--quiet")
317
+ # nothing staged; skip commit
318
+ return
319
+ end
320
+ system("git", "commit", "-m", message)
321
+ end
322
+
323
+ def commit_subject
324
+ env = ENV["KETTLE_README_BACKERS_COMMIT_SUBJECT"].to_s
325
+ return env unless env.strip.empty?
326
+ # Fallback to .opencollective.yml key: readme-backers-commit-subject
327
+ if File.file?(OC_YML_PATH)
328
+ begin
329
+ yml = YAML.safe_load(File.read(OC_YML_PATH))
330
+ if yml.is_a?(Hash)
331
+ from_yml = yml["readme-backers-commit-subject"] || yml[:"readme-backers-commit-subject"]
332
+ from_yml = from_yml.to_s if from_yml
333
+ return from_yml unless from_yml.nil? || from_yml.strip.empty?
334
+ end
335
+ rescue StandardError
336
+ # ignore yaml read errors and fall back to default
337
+ end
338
+ end
339
+ COMMIT_SUBJECT_DEFAULT
340
+ end
341
+
342
+ def git_repo?
343
+ system("git", "rev-parse", "--is-inside-work-tree", out: File::NULL, err: File::NULL)
344
+ end
345
+
346
+ def escape_text(text)
347
+ text.gsub("[", "\\[").gsub("]", "\\]")
348
+ end
349
+ end
350
+ end
351
+ end
352
+
353
+ if __FILE__ == $PROGRAM_NAME
354
+ Kettle::Dev::ReadmeBackers.new.run!
355
+ end
@@ -0,0 +1,327 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # kettle-release: Automate release steps from CONTRIBUTING.md
5
+ # - Runs sanity checks
6
+ # - Ensures version/changelog updated (with confirmation)
7
+ # - Commits and pushes a release prep commit
8
+ # - Ensures on trunk, up-to-date
9
+ # - Exports SOURCE_DATE_EPOCH for reproducible checksums
10
+ # - Runs `bundle exec rake build` (expects PEM password unless SKIP_GEM_SIGNING)
11
+ # - If signing not skipped and no public cert in certs/<user>.pem, aborts with guidance
12
+ # - Runs bin/gem_checksums
13
+ # - Runs `bundle exec rake release` (expects PEM password and RubyGems MFA OTP)
14
+
15
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
16
+
17
+ require "rubygems"
18
+ require "bundler/setup"
19
+
20
+ require "open3"
21
+ require "shellwords"
22
+ require "time"
23
+ require "fileutils"
24
+ require "net/http"
25
+ require "json"
26
+ require "uri"
27
+ require "kettle/dev/ci_helpers"
28
+ require "ruby-progressbar"
29
+
30
+ module Kettle
31
+ module Dev
32
+ class ReleaseCLI
33
+ def initialize
34
+ @root = File.expand_path("..", __dir__)
35
+ end
36
+
37
+ def run
38
+ puts "== kettle-release =="
39
+
40
+ run_cmd!("bin/setup")
41
+ run_cmd!("bin/rake")
42
+
43
+ version = detect_version
44
+ puts "Detected version: #{version.inspect}"
45
+ puts "Have you updated lib/**/version.rb and CHANGELOG.md for v#{version}? [y/N]"
46
+ print("> ")
47
+ ans = $stdin.gets&.strip
48
+ abort("Aborted: please update version.rb and CHANGELOG.md, then re-run.") unless ans&.downcase&.start_with?("y")
49
+
50
+ # Re-run checks (and refresh Gemfile.lock)
51
+ run_cmd!("bin/setup")
52
+ run_cmd!("bin/rake")
53
+
54
+ # Update Appraisal gemfiles if Appraisals file is present
55
+ appraisals_path = File.join(@root, "Appraisals")
56
+ if File.file?(appraisals_path)
57
+ puts "Appraisals detected at #{appraisals_path}. Running: bin/rake appraisal:update"
58
+ run_cmd!("bin/rake appraisal:update")
59
+ else
60
+ puts "No Appraisals file found; skipping appraisal:update"
61
+ end
62
+
63
+ ensure_git_user!
64
+ commit_release_prep!(version)
65
+
66
+ trunk = detect_trunk_branch
67
+ feature = current_branch
68
+ puts "Trunk branch detected: #{trunk}"
69
+ ensure_trunk_synced_before_push!(trunk, feature)
70
+
71
+ push!
72
+
73
+ # After pushing, ensure the CI workflows for this project are passing
74
+ monitor_workflows_after_push!
75
+
76
+ # If all workflows are passing, merge the feature branch into trunk and push trunk
77
+ merge_feature_into_trunk_and_push!(trunk, feature)
78
+
79
+ # Ensure we are on trunk for the remaining steps
80
+ checkout!(trunk)
81
+ pull!(trunk)
82
+
83
+ export_source_date_epoch!
84
+
85
+ ensure_signing_setup_or_skip!
86
+ # Build: expect PEM password prompt unless SKIP_GEM_SIGNING
87
+ puts "Running build (you may be prompted for the signing key password)..."
88
+ run_cmd!("bundle exec rake build")
89
+
90
+ # Checksums (commits, but does not push)
91
+ run_cmd!("bin/gem_checksums")
92
+
93
+ # Release: expect PEM password + RubyGems MFA OTP
94
+ puts "Running release (you may be prompted for signing key password and RubyGems MFA OTP)..."
95
+ run_cmd!("bundle exec rake release")
96
+
97
+ puts "\nRelease complete. Don't forget to push the checksums commit if needed."
98
+ end
99
+
100
+ private
101
+
102
+ # Monitor GitHub Actions workflows discovered by ci:act logic.
103
+ # Checks one workflow per second in a round-robin loop until all pass, or any fails.
104
+ def monitor_workflows_after_push!
105
+ root = Kettle::Dev::CIHelpers.project_root
106
+ workflows = Kettle::Dev::CIHelpers.workflows_list(root)
107
+ if workflows.empty?
108
+ puts "No workflows detected under .github/workflows; skipping CI checks."
109
+ return
110
+ end
111
+
112
+ owner, repo = Kettle::Dev::CIHelpers.repo_info
113
+ branch = Kettle::Dev::CIHelpers.current_branch
114
+ unless owner && repo && branch
115
+ puts "Unable to determine repository or branch; skipping CI checks."
116
+ return
117
+ end
118
+
119
+ total = workflows.size
120
+ passed = {}
121
+ idx = 0
122
+ puts "Ensuring CI workflows pass on branch #{branch} (#{owner}/#{repo})"
123
+ pbar = ProgressBar.create(title: "CI", total: total, format: "%t %b %c/%C", length: 30)
124
+
125
+ loop do
126
+ wf = workflows[idx]
127
+ run = Kettle::Dev::CIHelpers.latest_run(owner: owner, repo: repo, workflow_file: wf, branch: branch)
128
+ if run
129
+ if Kettle::Dev::CIHelpers.success?(run)
130
+ unless passed[wf]
131
+ passed[wf] = true
132
+ pbar.increment
133
+ end
134
+ elsif Kettle::Dev::CIHelpers.failed?(run)
135
+ # Fail fast with link to the failed run
136
+ puts
137
+ url = run["html_url"] || "https://github.com/#{owner}/#{repo}/actions/workflows/#{wf}"
138
+ abort("Workflow failed: #{wf} -> #{url}")
139
+ end
140
+ end
141
+
142
+ break if passed.size == total
143
+
144
+ idx = (idx + 1) % total
145
+ sleep(1)
146
+ end
147
+ pbar.finish unless pbar.finished?
148
+ puts "\nAll workflows passing (#{passed.size}/#{total})."
149
+ end
150
+
151
+ def run_cmd!(cmd)
152
+ puts "$ #{cmd}"
153
+ success = system(cmd)
154
+ abort("Command failed: #{cmd}") unless success
155
+ end
156
+
157
+ def git_output(args)
158
+ out, status = Open3.capture2("git", *args)
159
+ [out.strip, status.success?]
160
+ end
161
+
162
+ def check_git_clean!
163
+ out, ok = git_output(["status", "--porcelain"])
164
+ abort("Git working tree is not clean. Commit/stash changes before releasing.\n\n#{out}") unless ok && out.empty?
165
+ end
166
+
167
+ def ensure_git_user!
168
+ name, ok1 = git_output(["config", "user.name"])
169
+ email, ok2 = git_output(["config", "user.email"])
170
+ abort("Git user.name or user.email not configured.") unless ok1 && ok2 && !name.empty? && !email.empty?
171
+ end
172
+
173
+ def detect_version
174
+ # Look for lib/**/version.rb and extract VERSION constant string
175
+ candidates = Dir[File.join(@root, "lib", "**", "version.rb")]
176
+ abort("Could not find version.rb under lib/**.") if candidates.empty?
177
+ path = candidates.min
178
+ content = File.read(path)
179
+ m = content.match(/VERSION\s*=\s*(["'])([^"']+)\1/)
180
+ abort("VERSION constant not found in #{path}.") unless m
181
+ m[2]
182
+ end
183
+
184
+ def commit_release_prep!(version)
185
+ msg = "🔖 Prepare release v#{version}"
186
+ # Only commit if there are changes (version/changelog)
187
+ out, _ = git_output(["status", "--porcelain"])
188
+ if out.empty?
189
+ puts "No changes to commit for release prep (continuing)."
190
+ else
191
+ run_cmd!(%(git commit -am #{Shellwords.escape(msg)}))
192
+ end
193
+ end
194
+
195
+ def push!
196
+ puts "$ git push"
197
+ success = system("git push")
198
+ unless success
199
+ warn("Normal push failed; retrying with force push...")
200
+ run_cmd!("git push -f")
201
+ end
202
+ end
203
+
204
+ def detect_trunk_branch
205
+ out, ok = git_output(["remote", "show", "origin"])
206
+ abort("Failed to get origin remote info.") unless ok
207
+ m = out.lines.find { |l| l.include?("HEAD branch") }
208
+ abort("Unable to detect trunk branch from origin.") unless m
209
+ m.split.last
210
+ end
211
+
212
+ def checkout!(branch)
213
+ run_cmd!("git checkout #{Shellwords.escape(branch)}")
214
+ end
215
+
216
+ def pull!(branch)
217
+ run_cmd!("git pull origin #{Shellwords.escape(branch)}")
218
+ end
219
+
220
+ def current_branch
221
+ out, ok = git_output(["rev-parse", "--abbrev-ref", "HEAD"])
222
+ ok ? out : nil
223
+ end
224
+
225
+ def list_remotes
226
+ out, ok = git_output(["remote"])
227
+ ok ? out.split(/\s+/).reject(&:empty?) : []
228
+ end
229
+
230
+ def has_remote?(name)
231
+ list_remotes.include?(name)
232
+ end
233
+
234
+ def remote_branch_exists?(remote, branch)
235
+ _out, ok = git_output(["show-ref", "--verify", "--quiet", "refs/remotes/#{remote}/#{branch}"])
236
+ ok
237
+ end
238
+
239
+ def ahead_behind_counts(local_ref, remote_ref)
240
+ out, ok = git_output(["rev-list", "--left-right", "--count", "#{local_ref}...#{remote_ref}"])
241
+ return [0, 0] unless ok && !out.empty?
242
+ parts = out.split
243
+ left = parts[0].to_i
244
+ right = parts[1].to_i
245
+ [left, right]
246
+ end
247
+
248
+ def trunk_behind_remote?(trunk, remote)
249
+ # If the remote branch doesn't exist, treat as not behind
250
+ return false unless remote_branch_exists?(remote, trunk)
251
+ _ahead, behind = ahead_behind_counts(trunk, "#{remote}/#{trunk}")
252
+ behind.positive?
253
+ end
254
+
255
+ def ensure_trunk_synced_before_push!(trunk, feature)
256
+ if has_remote?("all")
257
+ puts "Remote 'all' detected. Fetching from all remotes and enforcing strict trunk parity..."
258
+ run_cmd!("git fetch --all")
259
+ remotes = list_remotes
260
+ missing_from = []
261
+ remotes.each do |r|
262
+ next if r == "all"
263
+ if remote_branch_exists?(r, trunk)
264
+ _ahead, behind = ahead_behind_counts(trunk, "#{r}/#{trunk}")
265
+ missing_from << r if behind.positive?
266
+ end
267
+ end
268
+ unless missing_from.empty?
269
+ abort("Local #{trunk} is missing commits present on: #{missing_from.join(", ")}. Please sync trunk first.")
270
+ end
271
+ puts "Local #{trunk} has all commits from remotes: #{(remotes - ["all"]).join(", ")}"
272
+ return
273
+ end
274
+
275
+ # Default behavior: ensure local trunk is not behind origin/trunk; if it is, rebase flows
276
+ run_cmd!("git fetch origin #{Shellwords.escape(trunk)}")
277
+ if trunk_behind_remote?(trunk, "origin")
278
+ puts "Local #{trunk} is behind origin/#{trunk}. Rebasing..."
279
+ cur = current_branch
280
+ checkout!(trunk) unless cur == trunk
281
+ run_cmd!("git pull --rebase origin #{Shellwords.escape(trunk)}")
282
+ checkout!(feature) unless feature.nil? || feature == trunk
283
+ run_cmd!("git rebase #{Shellwords.escape(trunk)}")
284
+ puts "Rebase complete. Will push updated branch next."
285
+ else
286
+ puts "Local #{trunk} is up to date with origin/#{trunk}."
287
+ end
288
+ end
289
+
290
+ def merge_feature_into_trunk_and_push!(trunk, feature)
291
+ return if feature.nil? || feature == trunk
292
+ puts "Merging #{feature} into #{trunk} (after CI success)..."
293
+ checkout!(trunk)
294
+ run_cmd!("git pull --rebase origin #{Shellwords.escape(trunk)}")
295
+ run_cmd!("git merge #{Shellwords.escape(feature)}")
296
+ run_cmd!("git push origin #{Shellwords.escape(trunk)}")
297
+ puts "Merged #{feature} into #{trunk} and pushed. The PR (if any) should auto-close."
298
+ end
299
+
300
+ def export_source_date_epoch!
301
+ epoch = Time.now.to_i
302
+ ENV["SOURCE_DATE_EPOCH"] = epoch.to_s
303
+ puts "Exported SOURCE_DATE_EPOCH=#{epoch}"
304
+ end
305
+
306
+ def ensure_signing_setup_or_skip!
307
+ return if ENV.key?("SKIP_GEM_SIGNING")
308
+
309
+ user = ENV.fetch("GEM_CERT_USER", ENV["USER"])
310
+ cert_path = File.join(@root, "certs", "#{user}.pem")
311
+ unless File.exist?(cert_path)
312
+ abort(<<~MSG)
313
+ Gem signing appears enabled but no public cert found at:
314
+ #{cert_path}
315
+ Add your public key to certs/<USER>.pem (or set GEM_CERT_USER), or set SKIP_GEM_SIGNING to build unsigned.
316
+ MSG
317
+ end
318
+ puts "Found signing cert: #{cert_path}"
319
+ puts "When prompted during build/release, enter the PEM password for ~/.ssh/gem-private_key.pem"
320
+ end
321
+ end
322
+ end
323
+ end
324
+
325
+ if __FILE__ == $PROGRAM_NAME
326
+ Kettle::Dev::ReleaseCLI.new.run
327
+ end
@@ -6,7 +6,7 @@ module Kettle
6
6
  module Version
7
7
  # The gem version.
8
8
  # @return [String]
9
- VERSION = "1.0.0"
9
+ VERSION = "1.0.1"
10
10
  end
11
11
  end
12
12
  end
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.0.0
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -188,7 +188,10 @@ description: "\U0001F372 Kettle::Dev is a meta tool from kettle-rb to streamline
188
188
  Fund overlooked open source projects - bottom of stack, dev/test dependencies: floss-funding.dev"
189
189
  email:
190
190
  - floss@galtzo.com
191
- executables: []
191
+ executables:
192
+ - kettle-commit-msg
193
+ - kettle-readme-backers
194
+ - kettle-release
192
195
  extensions: []
193
196
  extra_rdoc_files:
194
197
  - CHANGELOG.md
@@ -202,6 +205,8 @@ extra_rdoc_files:
202
205
  - SECURITY.md
203
206
  - checksums/kettle-dev-1.0.0.gem.sha256
204
207
  - checksums/kettle-dev-1.0.0.gem.sha512
208
+ - checksums/kettle-dev-1.0.1.gem.sha256
209
+ - checksums/kettle-dev-1.0.1.gem.sha512
205
210
  files:
206
211
  - CHANGELOG.md
207
212
  - CITATION.cff
@@ -214,6 +219,11 @@ files:
214
219
  - SECURITY.md
215
220
  - checksums/kettle-dev-1.0.0.gem.sha256
216
221
  - checksums/kettle-dev-1.0.0.gem.sha512
222
+ - checksums/kettle-dev-1.0.1.gem.sha256
223
+ - checksums/kettle-dev-1.0.1.gem.sha512
224
+ - exe/kettle-commit-msg
225
+ - exe/kettle-readme-backers
226
+ - exe/kettle-release
217
227
  - lib/kettle-dev.rb
218
228
  - lib/kettle/dev.rb
219
229
  - lib/kettle/dev/ci_helpers.rb
@@ -229,10 +239,10 @@ licenses:
229
239
  - MIT
230
240
  metadata:
231
241
  homepage_uri: https://kettle-dev.galtzo.com/
232
- source_code_uri: https://github.com/galtzo-floss/kettle-dev/tree/v1.0.0
233
- changelog_uri: https://github.com/galtzo-floss/kettle-dev/blob/v1.0.0/CHANGELOG.md
242
+ source_code_uri: https://github.com/galtzo-floss/kettle-dev/tree/v1.0.1
243
+ changelog_uri: https://github.com/galtzo-floss/kettle-dev/blob/v1.0.1/CHANGELOG.md
234
244
  bug_tracker_uri: https://github.com/galtzo-floss/kettle-dev/issues
235
- documentation_uri: https://www.rubydoc.info/gems/kettle-dev/1.0.0
245
+ documentation_uri: https://www.rubydoc.info/gems/kettle-dev/1.0.1
236
246
  funding_uri: https://github.com/sponsors/pboling
237
247
  wiki_uri: https://github.com/galtzo-floss/kettle-dev/wiki
238
248
  news_uri: https://www.railsbling.com/tags/kettle-dev
metadata.gz.sig CHANGED
Binary file