Skip to content

Commit d1f4cd9

Browse files
committed
added custom changeset tooling, will be used for CI publishing
1 parent 529a974 commit d1f4cd9

6 files changed

Lines changed: 428 additions & 0 deletions

File tree

Makefile

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,20 @@ turnkey_client: turnkey_client_inputs/public_api.swagger.json turnkey_client_inp
1212

1313
clean:
1414
rm -rf turnkey_client
15+
16+
.PHONY: changeset
17+
changeset:
18+
ruby tool/changeset.rb
19+
20+
.PHONY: version
21+
version:
22+
ruby tool/changeset_version.rb
23+
24+
.PHONY: changelog
25+
changelog:
26+
ruby tool/changeset_changelog.rb
27+
28+
.PHONY: prepare-release
29+
prepare-release:
30+
ruby tool/changeset_version.rb
31+
ruby tool/changeset_changelog.rb

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,21 @@ And run:
8989
$ rubocop
9090
```
9191

92+
## Contributing
93+
94+
Before opening a PR containing your changes, please create a changeset detailing the package bump and a brief note on what has changed.
95+
> [!NOTE]
96+
> - The note is what will be added to the changelog
97+
> - Quick version bump guide:
98+
> - patch: Bug fixes and small changes (0.0.1 → 0.0.2)
99+
> - minor: New features, backwards compatible (0.0.1 → 0.1.0)
100+
> - major: Breaking changes (0.0.1 → 1.0.0)
101+
102+
**Run this make cmd to create a new changeset:**
103+
```sh
104+
make changeset
105+
```
106+
92107
## Releasing on Rubygems.org
93108

94109
To build and release:

tool/changeset.rb

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/usr/bin/env ruby
2+
require 'fileutils'
3+
require_relative 'changeset_lib'
4+
5+
include ChangesetLib
6+
7+
def prompt_bump
8+
puts 'Select bump type:'
9+
puts ' 1) patch'
10+
puts ' 2) minor'
11+
puts ' 3) major'
12+
13+
loop do
14+
print 'Choice (1-3): '
15+
case $stdin.gets&.strip
16+
when '1' then return 'patch'
17+
when '2' then return 'minor'
18+
when '3' then return 'major'
19+
else puts 'Invalid choice, please enter 1, 2, or 3.'
20+
end
21+
end
22+
end
23+
24+
def prompt_line(label)
25+
print label
26+
$stdin.gets&.chomp || ''
27+
end
28+
29+
def prompt_multiline
30+
lines = []
31+
loop do
32+
line = $stdin.gets
33+
break if line.nil?
34+
break if line.strip == '.'
35+
lines << line.chomp
36+
end
37+
lines.pop while !lines.empty? && lines.last.strip.empty?
38+
lines.empty? ? '_No additional notes._' : lines.join("\n")
39+
end
40+
41+
def build_changeset_content(title:, date:, bump:, note:)
42+
<<~CONTENT
43+
---
44+
title: "#{escape_yaml_string(title)}"
45+
date: "#{date}"
46+
bump: "#{bump}"
47+
---
48+
49+
#{note}
50+
CONTENT
51+
end
52+
53+
def main
54+
puts '=== Create Changeset ==='
55+
puts
56+
57+
bump = prompt_bump
58+
puts
59+
60+
title = prompt_line('Short title for this change: ')
61+
abort('Error: title cannot be empty.') if title.strip.empty?
62+
63+
puts
64+
puts 'Enter a longer description (markdown allowed).'
65+
puts 'End input with a single "." on its own line:'
66+
note = prompt_multiline
67+
68+
now = Time.now
69+
filename = "#{format_timestamp(now)}-#{slugify(title)}.md"
70+
FileUtils.mkdir_p(CHANGESET_DIR)
71+
72+
filepath = File.join(CHANGESET_DIR, filename)
73+
content = build_changeset_content(
74+
title: title.strip,
75+
date: date_only(now),
76+
bump: bump,
77+
note: note
78+
)
79+
80+
File.write(filepath, content)
81+
puts
82+
puts "Changeset written to #{filepath}"
83+
end
84+
85+
main

tool/changeset_changelog.rb

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
#!/usr/bin/env ruby
2+
require 'fileutils'
3+
require_relative 'changeset_lib'
4+
5+
include ChangesetLib
6+
7+
def build_release_section(version, date, changes)
8+
by_bump = { 'patch' => [], 'minor' => [], 'major' => [] }
9+
10+
changes.each do |c|
11+
key = %w[patch minor major].include?(c['bump']) ? c['bump'] : 'patch'
12+
by_bump[key] << c
13+
end
14+
15+
lines = []
16+
lines << "## #{version} -- #{date}"
17+
lines << ''
18+
19+
[
20+
['patch', 'Patch Changes'],
21+
['minor', 'Minor Changes'],
22+
['major', 'Major Changes']
23+
].each do |key, heading|
24+
next if by_bump[key].empty?
25+
26+
lines << "### #{heading}"
27+
by_bump[key].each do |change|
28+
note = change['note']
29+
title = change['title']
30+
if note == '_No additional notes._'
31+
lines << "- #{title}"
32+
else
33+
lines << "- #{note}"
34+
end
35+
end
36+
lines << ''
37+
end
38+
39+
lines.join("\n") + "\n"
40+
end
41+
42+
def merge_changelog(existing, new_section)
43+
if existing.strip.empty?
44+
return "#{CHANGELOG_HEADER}\n\n#{new_section}"
45+
end
46+
47+
trimmed = existing.lstrip
48+
unless trimmed.start_with?(CHANGELOG_HEADER)
49+
return "#{CHANGELOG_HEADER}\n\n#{new_section}#{existing}"
50+
end
51+
52+
# Insert new section right after the "# Changelog" header line
53+
lines = existing.split("\n")
54+
header_line = lines.first
55+
rest = lines[1..].join("\n").lstrip
56+
57+
result = "#{header_line}\n\n#{new_section}"
58+
result += rest unless rest.empty?
59+
result
60+
end
61+
62+
def main
63+
meta = read_release_meta
64+
version = meta['toVersion']
65+
date = meta['date']
66+
changes = meta['changes']
67+
68+
if changes.nil? || changes.empty?
69+
puts 'No changes in release metadata -- nothing to changelog.'
70+
return
71+
end
72+
73+
new_section = build_release_section(version, date, changes)
74+
75+
existing = File.exist?(CHANGELOG_FILE) ? File.read(CHANGELOG_FILE) : ''
76+
merged = merge_changelog(existing, new_section)
77+
File.write(CHANGELOG_FILE, merged)
78+
puts "Updated #{CHANGELOG_FILE} for v#{version}"
79+
80+
delete_processed_changesets(meta)
81+
puts 'Deleted processed changesets and release metadata.'
82+
end
83+
84+
main

tool/changeset_lib.rb

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
require 'json'
2+
require 'yaml'
3+
require 'fileutils'
4+
5+
module ChangesetLib
6+
CHANGESET_DIR = '.changesets'
7+
RELEASE_META_FILE = '_current_release.json'
8+
VERSION_FILE = 'turnkey_client/lib/turnkey_client/version.rb'
9+
CONFIG_FILE = 'turnkey_client_inputs/config.json'
10+
CHANGELOG_FILE = 'CHANGELOG.md'
11+
CHANGELOG_HEADER = '# Changelog'
12+
13+
ChangesetEntry = Struct.new(:path, :title, :date, :bump, :note, keyword_init: true)
14+
15+
def slugify(str)
16+
slug = str.downcase
17+
.gsub(/[^a-z0-9\s_-]/, '')
18+
.gsub(/[\s_-]+/, '-')
19+
.gsub(/\A-+|-+\z/, '')
20+
slug.empty? ? 'changeset' : slug
21+
end
22+
23+
def format_timestamp(time)
24+
time.strftime('%Y%m%d-%H%M%S')
25+
end
26+
27+
def date_only(time)
28+
time.strftime('%Y-%m-%d')
29+
end
30+
31+
def today_date
32+
date_only(Time.now)
33+
end
34+
35+
def escape_yaml_string(str)
36+
str.gsub('\\', '\\\\\\\\').gsub('"', '\\"')
37+
end
38+
39+
def parse_version(version_str)
40+
base = version_str.strip.split(/[-+]/, 2).first
41+
parts = base.split('.')
42+
unless parts.length == 3 && parts.all? { |p| p.match?(/\A\d+\z/) }
43+
raise "Invalid version format: '#{version_str}', expected X.Y.Z"
44+
end
45+
parts.map(&:to_i)
46+
end
47+
48+
def next_version(current, bump)
49+
major, minor, patch = parse_version(current)
50+
case bump
51+
when 'major'
52+
"#{major + 1}.0.0"
53+
when 'minor'
54+
"#{major}.#{minor + 1}.0"
55+
when 'patch'
56+
"#{major}.#{minor}.#{patch + 1}"
57+
else
58+
raise "Unknown bump type: '#{bump}'"
59+
end
60+
end
61+
62+
def bump_level(bump)
63+
case bump
64+
when 'patch' then 1
65+
when 'minor' then 2
66+
when 'major' then 3
67+
else raise "Unknown bump type: '#{bump}'"
68+
end
69+
end
70+
71+
def max_bump(bumps)
72+
bumps.max_by { |b| bump_level(b) }
73+
end
74+
75+
def read_current_version
76+
unless File.exist?(VERSION_FILE)
77+
raise "Cannot read version: #{VERSION_FILE} does not exist"
78+
end
79+
content = File.read(VERSION_FILE)
80+
match = content.match(/VERSION\s*=\s*['"]([^'"]+)['"]/)
81+
unless match
82+
raise "Cannot find VERSION constant in #{VERSION_FILE}"
83+
end
84+
match[1]
85+
end
86+
87+
def write_version_rb(new_version)
88+
content = File.read(VERSION_FILE)
89+
updated = content.sub(/VERSION\s*=\s*['"][^'"]+['"]/, "VERSION = '#{new_version}'")
90+
File.write(VERSION_FILE, updated)
91+
end
92+
93+
def write_config_json(new_version)
94+
content = File.read(CONFIG_FILE)
95+
data = JSON.parse(content)
96+
data['gemVersion'] = new_version
97+
File.write(CONFIG_FILE, JSON.pretty_generate(data) + "\n")
98+
end
99+
100+
def load_changesets
101+
dir = CHANGESET_DIR
102+
return [] unless Dir.exist?(dir)
103+
104+
Dir.glob(File.join(dir, '*.md'))
105+
.reject { |f| File.basename(f).start_with?('_') }
106+
.sort
107+
.map { |f| parse_changeset_file(f) }
108+
.compact
109+
end
110+
111+
def parse_changeset_file(path)
112+
raw = File.read(path).strip
113+
unless raw.start_with?('---')
114+
warn "warning: #{path} does not start with frontmatter delimiter, skipping"
115+
return nil
116+
end
117+
118+
parts = raw.split(/^---\s*$/m)
119+
if parts.length < 3
120+
warn "warning: #{path} has malformed frontmatter, skipping"
121+
return nil
122+
end
123+
124+
frontmatter = YAML.safe_load(parts[1])
125+
body = parts[2..].join('---').strip
126+
body = '_No additional notes._' if body.empty?
127+
128+
ChangesetEntry.new(
129+
path: path,
130+
title: frontmatter['title'],
131+
date: frontmatter['date'] || today_date,
132+
bump: frontmatter['bump'] || 'patch',
133+
note: body
134+
)
135+
rescue => e
136+
warn "warning: failed to parse changeset #{path}: #{e.message}"
137+
nil
138+
end
139+
140+
def read_release_meta
141+
meta_path = File.join(CHANGESET_DIR, RELEASE_META_FILE)
142+
unless File.exist?(meta_path)
143+
raise "No release metadata found at #{meta_path}. Run `make version` first."
144+
end
145+
JSON.parse(File.read(meta_path))
146+
end
147+
148+
def write_release_meta(meta)
149+
FileUtils.mkdir_p(CHANGESET_DIR)
150+
meta_path = File.join(CHANGESET_DIR, RELEASE_META_FILE)
151+
File.write(meta_path, JSON.pretty_generate(meta) + "\n")
152+
end
153+
154+
def delete_processed_changesets(meta)
155+
(meta['changes'] || []).each do |change|
156+
path = change['changesetPath']
157+
if path && File.exist?(path)
158+
File.delete(path)
159+
end
160+
end
161+
162+
meta_path = File.join(CHANGESET_DIR, RELEASE_META_FILE)
163+
File.delete(meta_path) if File.exist?(meta_path)
164+
end
165+
end

0 commit comments

Comments
 (0)