From 81993d16548d01c2bcc72769fb46e1ca8b7283e3 Mon Sep 17 00:00:00 2001 From: Ethan Konkolowicz Date: Wed, 28 Jan 2026 14:43:09 -0500 Subject: [PATCH] added custom changeset tooling, will be used for CI publishing --- Makefile | 17 ++++ README.md | 15 ++++ tool/changeset.rb | 85 +++++++++++++++++++ tool/changeset_changelog.rb | 84 ++++++++++++++++++ tool/changeset_lib.rb | 165 ++++++++++++++++++++++++++++++++++++ tool/changeset_version.rb | 62 ++++++++++++++ 6 files changed, 428 insertions(+) create mode 100644 tool/changeset.rb create mode 100644 tool/changeset_changelog.rb create mode 100644 tool/changeset_lib.rb create mode 100644 tool/changeset_version.rb diff --git a/Makefile b/Makefile index 53b1db9..2ec57c1 100644 --- a/Makefile +++ b/Makefile @@ -12,3 +12,20 @@ turnkey_client: turnkey_client_inputs/public_api.swagger.json turnkey_client_inp clean: rm -rf turnkey_client + +.PHONY: changeset +changeset: + ruby tool/changeset.rb + +.PHONY: version +version: + ruby tool/changeset_version.rb + +.PHONY: changelog +changelog: + ruby tool/changeset_changelog.rb + +.PHONY: prepare-release +prepare-release: + ruby tool/changeset_version.rb + ruby tool/changeset_changelog.rb diff --git a/README.md b/README.md index 82c4734..e201a82 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,21 @@ And run: $ rubocop ``` +## Contributing + +Before opening a PR containing your changes, please create a changeset detailing the package bump and a brief note on what has changed. +> [!NOTE] +> - The note is what will be added to the changelog +> - Quick version bump guide: +> - patch: Bug fixes and small changes (0.0.1 → 0.0.2) +> - minor: New features, backwards compatible (0.0.1 → 0.1.0) +> - major: Breaking changes (0.0.1 → 1.0.0) + +**Run this make cmd to create a new changeset:** +```sh +$ make changeset +``` + ## Releasing on Rubygems.org To build and release: diff --git a/tool/changeset.rb b/tool/changeset.rb new file mode 100644 index 0000000..4ffb7ea --- /dev/null +++ b/tool/changeset.rb @@ -0,0 +1,85 @@ +#!/usr/bin/env ruby +require 'fileutils' +require_relative 'changeset_lib' + +include ChangesetLib + +def prompt_bump + puts 'Select bump type:' + puts ' 1) patch' + puts ' 2) minor' + puts ' 3) major' + + loop do + print 'Choice (1-3): ' + case $stdin.gets&.strip + when '1' then return 'patch' + when '2' then return 'minor' + when '3' then return 'major' + else puts 'Invalid choice, please enter 1, 2, or 3.' + end + end +end + +def prompt_line(label) + print label + $stdin.gets&.chomp || '' +end + +def prompt_multiline + lines = [] + loop do + line = $stdin.gets + break if line.nil? + break if line.strip == '.' + lines << line.chomp + end + lines.pop while !lines.empty? && lines.last.strip.empty? + lines.empty? ? '_No additional notes._' : lines.join("\n") +end + +def build_changeset_content(title:, date:, bump:, note:) + <<~CONTENT + --- + title: "#{escape_yaml_string(title)}" + date: "#{date}" + bump: "#{bump}" + --- + + #{note} + CONTENT +end + +def main + puts '=== Create Changeset ===' + puts + + bump = prompt_bump + puts + + title = prompt_line('Short title for this change: ') + abort('Error: title cannot be empty.') if title.strip.empty? + + puts + puts 'Enter a longer description (markdown allowed).' + puts 'End input with a single "." on its own line:' + note = prompt_multiline + + now = Time.now + filename = "#{format_timestamp(now)}-#{slugify(title)}.md" + FileUtils.mkdir_p(CHANGESET_DIR) + + filepath = File.join(CHANGESET_DIR, filename) + content = build_changeset_content( + title: title.strip, + date: date_only(now), + bump: bump, + note: note + ) + + File.write(filepath, content) + puts + puts "Changeset written to #{filepath}" +end + +main diff --git a/tool/changeset_changelog.rb b/tool/changeset_changelog.rb new file mode 100644 index 0000000..401037e --- /dev/null +++ b/tool/changeset_changelog.rb @@ -0,0 +1,84 @@ +#!/usr/bin/env ruby +require 'fileutils' +require_relative 'changeset_lib' + +include ChangesetLib + +def build_release_section(version, date, changes) + by_bump = { 'patch' => [], 'minor' => [], 'major' => [] } + + changes.each do |c| + key = %w[patch minor major].include?(c['bump']) ? c['bump'] : 'patch' + by_bump[key] << c + end + + lines = [] + lines << "## #{version} -- #{date}" + lines << '' + + [ + ['patch', 'Patch Changes'], + ['minor', 'Minor Changes'], + ['major', 'Major Changes'] + ].each do |key, heading| + next if by_bump[key].empty? + + lines << "### #{heading}" + by_bump[key].each do |change| + note = change['note'] + title = change['title'] + if note == '_No additional notes._' + lines << "- #{title}" + else + lines << "- #{note}" + end + end + lines << '' + end + + lines.join("\n") + "\n" +end + +def merge_changelog(existing, new_section) + if existing.strip.empty? + return "#{CHANGELOG_HEADER}\n\n#{new_section}" + end + + trimmed = existing.lstrip + unless trimmed.start_with?(CHANGELOG_HEADER) + return "#{CHANGELOG_HEADER}\n\n#{new_section}#{existing}" + end + + # Insert new section right after the "# Changelog" header line + lines = existing.split("\n") + header_line = lines.first + rest = lines[1..].join("\n").lstrip + + result = "#{header_line}\n\n#{new_section}" + result += rest unless rest.empty? + result +end + +def main + meta = read_release_meta + version = meta['toVersion'] + date = meta['date'] + changes = meta['changes'] + + if changes.nil? || changes.empty? + puts 'No changes in release metadata -- nothing to changelog.' + return + end + + new_section = build_release_section(version, date, changes) + + existing = File.exist?(CHANGELOG_FILE) ? File.read(CHANGELOG_FILE) : '' + merged = merge_changelog(existing, new_section) + File.write(CHANGELOG_FILE, merged) + puts "Updated #{CHANGELOG_FILE} for v#{version}" + + delete_processed_changesets(meta) + puts 'Deleted processed changesets and release metadata.' +end + +main diff --git a/tool/changeset_lib.rb b/tool/changeset_lib.rb new file mode 100644 index 0000000..6474ec8 --- /dev/null +++ b/tool/changeset_lib.rb @@ -0,0 +1,165 @@ +require 'json' +require 'yaml' +require 'fileutils' + +module ChangesetLib + CHANGESET_DIR = '.changesets' + RELEASE_META_FILE = '_current_release.json' + VERSION_FILE = 'turnkey_client/lib/turnkey_client/version.rb' + CONFIG_FILE = 'turnkey_client_inputs/config.json' + CHANGELOG_FILE = 'CHANGELOG.md' + CHANGELOG_HEADER = '# Changelog' + + ChangesetEntry = Struct.new(:path, :title, :date, :bump, :note, keyword_init: true) + + def slugify(str) + slug = str.downcase + .gsub(/[^a-z0-9\s_-]/, '') + .gsub(/[\s_-]+/, '-') + .gsub(/\A-+|-+\z/, '') + slug.empty? ? 'changeset' : slug + end + + def format_timestamp(time) + time.strftime('%Y%m%d-%H%M%S') + end + + def date_only(time) + time.strftime('%Y-%m-%d') + end + + def today_date + date_only(Time.now) + end + + def escape_yaml_string(str) + str.gsub('\\', '\\\\\\\\').gsub('"', '\\"') + end + + def parse_version(version_str) + base = version_str.strip.split(/[-+]/, 2).first + parts = base.split('.') + unless parts.length == 3 && parts.all? { |p| p.match?(/\A\d+\z/) } + raise "Invalid version format: '#{version_str}', expected X.Y.Z" + end + parts.map(&:to_i) + end + + def next_version(current, bump) + major, minor, patch = parse_version(current) + case bump + when 'major' + "#{major + 1}.0.0" + when 'minor' + "#{major}.#{minor + 1}.0" + when 'patch' + "#{major}.#{minor}.#{patch + 1}" + else + raise "Unknown bump type: '#{bump}'" + end + end + + def bump_level(bump) + case bump + when 'patch' then 1 + when 'minor' then 2 + when 'major' then 3 + else raise "Unknown bump type: '#{bump}'" + end + end + + def max_bump(bumps) + bumps.max_by { |b| bump_level(b) } + end + + def read_current_version + unless File.exist?(VERSION_FILE) + raise "Cannot read version: #{VERSION_FILE} does not exist" + end + content = File.read(VERSION_FILE) + match = content.match(/VERSION\s*=\s*['"]([^'"]+)['"]/) + unless match + raise "Cannot find VERSION constant in #{VERSION_FILE}" + end + match[1] + end + + def write_version_rb(new_version) + content = File.read(VERSION_FILE) + updated = content.sub(/VERSION\s*=\s*['"][^'"]+['"]/, "VERSION = '#{new_version}'") + File.write(VERSION_FILE, updated) + end + + def write_config_json(new_version) + content = File.read(CONFIG_FILE) + data = JSON.parse(content) + data['gemVersion'] = new_version + File.write(CONFIG_FILE, JSON.pretty_generate(data) + "\n") + end + + def load_changesets + dir = CHANGESET_DIR + return [] unless Dir.exist?(dir) + + Dir.glob(File.join(dir, '*.md')) + .reject { |f| File.basename(f).start_with?('_') } + .sort + .map { |f| parse_changeset_file(f) } + .compact + end + + def parse_changeset_file(path) + raw = File.read(path).strip + unless raw.start_with?('---') + warn "warning: #{path} does not start with frontmatter delimiter, skipping" + return nil + end + + parts = raw.split(/^---\s*$/m) + if parts.length < 3 + warn "warning: #{path} has malformed frontmatter, skipping" + return nil + end + + frontmatter = YAML.safe_load(parts[1]) + body = parts[2..].join('---').strip + body = '_No additional notes._' if body.empty? + + ChangesetEntry.new( + path: path, + title: frontmatter['title'], + date: frontmatter['date'] || today_date, + bump: frontmatter['bump'] || 'patch', + note: body + ) + rescue => e + warn "warning: failed to parse changeset #{path}: #{e.message}" + nil + end + + def read_release_meta + meta_path = File.join(CHANGESET_DIR, RELEASE_META_FILE) + unless File.exist?(meta_path) + raise "No release metadata found at #{meta_path}. Run `make version` first." + end + JSON.parse(File.read(meta_path)) + end + + def write_release_meta(meta) + FileUtils.mkdir_p(CHANGESET_DIR) + meta_path = File.join(CHANGESET_DIR, RELEASE_META_FILE) + File.write(meta_path, JSON.pretty_generate(meta) + "\n") + end + + def delete_processed_changesets(meta) + (meta['changes'] || []).each do |change| + path = change['changesetPath'] + if path && File.exist?(path) + File.delete(path) + end + end + + meta_path = File.join(CHANGESET_DIR, RELEASE_META_FILE) + File.delete(meta_path) if File.exist?(meta_path) + end +end diff --git a/tool/changeset_version.rb b/tool/changeset_version.rb new file mode 100644 index 0000000..cfef99d --- /dev/null +++ b/tool/changeset_version.rb @@ -0,0 +1,62 @@ +#!/usr/bin/env ruby +require_relative 'changeset_lib' + +include ChangesetLib + +def main + # Warn if a release is already in progress + meta_path = File.join(CHANGESET_DIR, RELEASE_META_FILE) + if File.exist?(meta_path) + warn "warning: #{meta_path} already exists." + warn 'Run `make changelog` to process the current release before versioning again.' + warn 'Continuing will overwrite the existing release metadata.' + print 'Continue? (y/N): ' + answer = $stdin.gets&.strip&.downcase + abort('Aborted.') unless answer == 'y' + end + + changesets = load_changesets + if changesets.empty? + puts 'No pending changesets found -- nothing to version.' + return + end + + current_version = read_current_version + bumps = changesets.map(&:bump) + bump = max_bump(bumps) + new_version = next_version(current_version, bump) + + puts "Applying version bump: #{current_version} -> #{new_version} (#{bump})" + + write_version_rb(new_version) + puts " Updated #{VERSION_FILE}" + + write_config_json(new_version) + puts " Updated #{CONFIG_FILE}" + + changes = changesets.map do |cs| + { + 'title' => cs.title, + 'bump' => cs.bump, + 'note' => cs.note, + 'changesetPath' => cs.path + } + end + + meta = { + 'created' => Time.now.strftime('%Y-%m-%dT%H:%M:%S%z'), + 'date' => today_date, + 'fromVersion' => current_version, + 'toVersion' => new_version, + 'bump' => bump, + 'changes' => changes + } + + write_release_meta(meta) + puts + puts "Wrote release metadata to #{meta_path}" + puts + puts "#{changesets.length} changeset(s) processed. Version: #{current_version} -> #{new_version}" +end + +main