puma-release 0.1.0

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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +114 -0
  4. data/exe/puma-release +7 -0
  5. data/lib/puma_release/agent_client.rb +163 -0
  6. data/lib/puma_release/build_support.rb +46 -0
  7. data/lib/puma_release/changelog_generator.rb +171 -0
  8. data/lib/puma_release/changelog_validator.rb +85 -0
  9. data/lib/puma_release/ci_checker.rb +108 -0
  10. data/lib/puma_release/cli.rb +52 -0
  11. data/lib/puma_release/commands/build.rb +68 -0
  12. data/lib/puma_release/commands/github.rb +76 -0
  13. data/lib/puma_release/commands/prepare.rb +178 -0
  14. data/lib/puma_release/commands/run.rb +51 -0
  15. data/lib/puma_release/context.rb +167 -0
  16. data/lib/puma_release/contributor_resolver.rb +52 -0
  17. data/lib/puma_release/error.rb +5 -0
  18. data/lib/puma_release/events.rb +18 -0
  19. data/lib/puma_release/git_repo.rb +169 -0
  20. data/lib/puma_release/github_client.rb +163 -0
  21. data/lib/puma_release/link_reference_builder.rb +49 -0
  22. data/lib/puma_release/options.rb +47 -0
  23. data/lib/puma_release/release_range.rb +69 -0
  24. data/lib/puma_release/repo_files.rb +85 -0
  25. data/lib/puma_release/shell.rb +107 -0
  26. data/lib/puma_release/stage_detector.rb +66 -0
  27. data/lib/puma_release/ui.rb +36 -0
  28. data/lib/puma_release/upgrade_guide_writer.rb +106 -0
  29. data/lib/puma_release/version.rb +5 -0
  30. data/lib/puma_release/version_recommender.rb +151 -0
  31. data/lib/puma_release.rb +28 -0
  32. data/test/test_helper.rb +72 -0
  33. data/test/unit/agent_client_test.rb +116 -0
  34. data/test/unit/build_command_test.rb +23 -0
  35. data/test/unit/build_support_test.rb +6 -0
  36. data/test/unit/changelog_validator_test.rb +42 -0
  37. data/test/unit/context_test.rb +209 -0
  38. data/test/unit/contributor_resolver_test.rb +47 -0
  39. data/test/unit/git_repo_test.rb +169 -0
  40. data/test/unit/github_client_test.rb +90 -0
  41. data/test/unit/github_command_test.rb +153 -0
  42. data/test/unit/options_test.rb +17 -0
  43. data/test/unit/prepare_test.rb +136 -0
  44. data/test/unit/repo_files_test.rb +119 -0
  45. data/test/unit/run_test.rb +32 -0
  46. data/test/unit/shell_test.rb +29 -0
  47. data/test/unit/stage_detector_test.rb +72 -0
  48. metadata +143 -0
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaRelease
4
+ class ReleaseRange
5
+ attr_reader :context, :last_tag
6
+
7
+ def initialize(context, last_tag)
8
+ @context = context
9
+ @last_tag = last_tag
10
+ end
11
+
12
+ def items
13
+ @items ||= begin
14
+ seen_prs = {}
15
+ commits.filter_map do |commit|
16
+ pr = github.commit_pulls(context.metadata_repo, commit.fetch(:sha)).first
17
+ if pr
18
+ next if seen_prs[pr.fetch("number")]
19
+
20
+ seen_prs[pr.fetch("number")] = true
21
+ pull_request_item(pr, commit.fetch(:sha))
22
+ else
23
+ commit.merge(type: "commit")
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ def to_prompt_context
30
+ lines = ["Repository: #{context.metadata_repo}", "Release range: #{last_tag}..HEAD", "", "Changes:"]
31
+ items.each do |item|
32
+ if item.fetch(:type) == "pr"
33
+ labels = item.fetch(:labels)
34
+ lines << "- PR ##{item.fetch(:number)} #{item.fetch(:title)}"
35
+ lines << " Labels: #{labels.empty? ? "none" : labels.join(", ")}"
36
+ lines << " PR URL: #{item.fetch(:url)}"
37
+ lines << " Merge commit: #{item.fetch(:commit_url)}"
38
+ else
39
+ lines << "- Commit #{item.fetch(:sha)[0, 12]} #{item.fetch(:subject)}"
40
+ lines << " Commit URL: #{item.fetch(:commit_url)}"
41
+ end
42
+ end
43
+ lines.join("\n")
44
+ end
45
+
46
+ private
47
+
48
+ def commits
49
+ shell.output("git", "log", "--reverse", "--format=%H%x09%s", "#{last_tag}..HEAD").lines(chomp: true).map do |line|
50
+ sha, subject = line.split("\t", 2)
51
+ {sha:, subject:, commit_url: "https://github.com/#{context.metadata_repo}/commit/#{sha}"}
52
+ end
53
+ end
54
+
55
+ def pull_request_item(pr, sha)
56
+ {
57
+ type: "pr",
58
+ number: pr.fetch("number"),
59
+ title: pr.fetch("title"),
60
+ url: pr.fetch("html_url"),
61
+ commit_url: "https://github.com/#{context.metadata_repo}/commit/#{sha}",
62
+ labels: Array(pr["labels"]).map { |label| label.fetch("name") }
63
+ }
64
+ end
65
+
66
+ def shell = context.shell
67
+ def github = @github ||= GitHubClient.new(context)
68
+ end
69
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module PumaRelease
6
+ class RepoFiles
7
+ attr_reader :context
8
+
9
+ def initialize(context)
10
+ @context = context
11
+ end
12
+
13
+ def current_version
14
+ content[/PUMA_VERSION = VERSION = "([^"]+)"/, 1] || raise(Error, "Could not read current version")
15
+ end
16
+
17
+ def current_code_name
18
+ content[/CODE_NAME = "([^"]+)"/, 1] || raise(Error, "Could not read current code name")
19
+ end
20
+
21
+ def release_name(version)
22
+ return "v#{version}" unless codename_applicable?(version)
23
+
24
+ "v#{version} - #{current_code_name}"
25
+ end
26
+
27
+ def update_security!(new_version)
28
+ major = new_version.split(".").first.to_i
29
+ new_majors = [major, major - 1]
30
+ i = -1
31
+ updated = context.security_file.read.gsub(/Latest release in \d+\.x/) do
32
+ i += 1
33
+ i < new_majors.size ? "Latest release in #{new_majors[i]}.x" : $&
34
+ end
35
+ context.security_file.write(updated)
36
+ end
37
+
38
+ def update_version!(new_version, bump_type, codename: nil)
39
+ updated = content.sub(/PUMA_VERSION = VERSION = ".*"/, "PUMA_VERSION = VERSION = \"#{new_version}\"")
40
+ unless bump_type == "patch"
41
+ placeholder = codename || "INSERT CODENAME HERE"
42
+ updated = updated.sub(/CODE_NAME = ".*"/, "CODE_NAME = \"#{placeholder}\"")
43
+ end
44
+ context.version_file.write(updated)
45
+ end
46
+
47
+ def extract_history_section(version)
48
+ lines = context.history_file.readlines(chomp: true)
49
+ start = lines.index { |line| line.match?(/^## #{Regexp.escape(version)} /) }
50
+ return nil unless start
51
+
52
+ lines[(start + 1)..].take_while { |line| !line.start_with?("## ") }.join("\n").strip
53
+ end
54
+
55
+ def prepend_history_section!(version, changelog, refs)
56
+ header = "## #{version} / #{Date.today.strftime("%Y-%m-%d")}"
57
+ body = [header, changelog.strip, context.history_file.read].join("\n\n")
58
+ context.history_file.write(body)
59
+ insert_link_refs!(refs)
60
+ end
61
+
62
+ def insert_link_refs!(refs)
63
+ return if refs.empty?
64
+
65
+ lines = context.history_file.readlines(chomp: true)
66
+ index = lines.index { |line| line.match?(/^\[#\d+\]:/) }
67
+ updated = if index
68
+ [*lines[0...index], *refs.lines(chomp: true), *lines[index..]].join("\n")
69
+ else
70
+ [context.history_file.read.rstrip, refs].join("\n")
71
+ end
72
+ context.history_file.write("#{updated}\n")
73
+ end
74
+
75
+ private
76
+
77
+ def codename_applicable?(version)
78
+ version.split(".").last == "0"
79
+ end
80
+
81
+ def content
82
+ context.version_file.read
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "shellwords"
6
+
7
+ module PumaRelease
8
+ class Shell
9
+ Result = Data.define(:stdout, :stderr, :success?, :exitstatus)
10
+
11
+ attr_reader :env, :cwd
12
+
13
+ def initialize(env: ENV, cwd: Dir.pwd)
14
+ @env = env.to_h
15
+ @cwd = cwd
16
+ end
17
+
18
+ def available?(command)
19
+ return File.file?(command) && File.executable?(command) if command.include?(File::SEPARATOR)
20
+
21
+ env.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |path|
22
+ candidate = File.join(path, command)
23
+ File.file?(candidate) && File.executable?(candidate)
24
+ end
25
+ end
26
+
27
+ def run(*command, stdin_data: nil, env_overrides: {}, allow_failure: false)
28
+ stdout, stderr, status = Open3.capture3(env.merge(env_overrides), *command, stdin_data:, chdir: cwd)
29
+ result = Result.new(stdout:, stderr:, success?: status.success?, exitstatus: status.exitstatus)
30
+ return result if result.success? || allow_failure
31
+
32
+ details = result.stderr.strip
33
+ details = result.stdout.strip if details.empty?
34
+ raise Error, [command.join(" "), details].reject(&:empty?).join(": ")
35
+ end
36
+
37
+ def output(*command, **options)
38
+ run(*command, **options).stdout
39
+ end
40
+
41
+ def stream_output(*command, stdin_data: nil, env_overrides: {})
42
+ stdout_buffer = +""
43
+ stderr_buffer = +""
44
+
45
+ Open3.popen3(env.merge(env_overrides), *command, chdir: cwd) do |stdin, stdout, stderr, wait_thr|
46
+ stdin.write(stdin_data) if stdin_data
47
+ stdin.close
48
+
49
+ stdout_thread = Thread.new do
50
+ stream_chunks(stdout) do |chunk|
51
+ $stdout.print(chunk)
52
+ $stdout.flush
53
+ stdout_buffer << chunk
54
+ yield chunk if block_given?
55
+ end
56
+ end
57
+
58
+ stderr_thread = Thread.new do
59
+ stream_chunks(stderr) { |chunk| stderr_buffer << chunk }
60
+ end
61
+
62
+ stdout_thread.join
63
+ stderr_thread.join
64
+ status = wait_thr.value
65
+ return stdout_buffer if status.success?
66
+
67
+ details = stderr_buffer.strip
68
+ details = stdout_buffer.strip if details.empty?
69
+ raise Error, [command.join(" "), details].reject(&:empty?).join(": ")
70
+ end
71
+ end
72
+
73
+ def stream_json_events(*command, stdin_data: nil, env_overrides: {})
74
+ Open3.popen3(env.merge(env_overrides), *command, chdir: cwd) do |stdin, stdout, stderr, wait_thr|
75
+ stdin.write(stdin_data) if stdin_data
76
+ stdin.close
77
+ stdout.each_line do |line|
78
+ next if line.strip.empty?
79
+ begin
80
+ yield JSON.parse(line)
81
+ rescue JSON::ParserError
82
+ end
83
+ end
84
+ status = wait_thr.value
85
+ raise Error, command.join(" ") unless status.success?
86
+ end
87
+ end
88
+
89
+ def optional_output(*command)
90
+ run(*command, allow_failure: true).stdout.strip
91
+ end
92
+
93
+ def split(command)
94
+ Shellwords.split(command)
95
+ end
96
+
97
+ private
98
+
99
+ def stream_chunks(io)
100
+ loop do
101
+ chunk = io.readpartial(1024)
102
+ yield chunk
103
+ end
104
+ rescue EOFError
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaRelease
4
+ class StageDetector
5
+ attr_reader :context, :git_repo, :repo_files, :github
6
+
7
+ def initialize(context, git_repo: GitRepo.new(context), repo_files: RepoFiles.new(context), github: GitHubClient.new(context))
8
+ @context = context
9
+ @git_repo = git_repo
10
+ @repo_files = repo_files
11
+ @github = github
12
+ end
13
+
14
+ def next_step
15
+ return :wait_for_merge if waiting_on_release_pr?
16
+ return :build if release_version_ahead_of_tag?
17
+ return :github if github_release_pending?
18
+ return :github if github_release_missing_for_current_tag?
19
+ return :complete if no_new_commits_since_last_release?
20
+
21
+ :prepare
22
+ end
23
+
24
+ private
25
+
26
+ def waiting_on_release_pr?
27
+ return true if git_repo.current_branch.start_with?("release-v")
28
+
29
+ !github.open_release_pr.nil?
30
+ end
31
+
32
+ def release_version_ahead_of_tag?
33
+ repo_files.current_version != git_repo.last_tag.delete_prefix("v")
34
+ end
35
+
36
+ def github_release_pending?
37
+ release = current_release
38
+ return false unless release
39
+
40
+ release.fetch("isDraft", false) || missing_assets?(release)
41
+ end
42
+
43
+ def github_release_missing_for_current_tag?
44
+ current_release.nil? && no_new_commits_since_last_release?
45
+ end
46
+
47
+ def no_new_commits_since_last_release?
48
+ git_repo.commits_since(git_repo.last_tag).zero?
49
+ end
50
+
51
+ def current_release
52
+ return @current_release if defined?(@current_release)
53
+
54
+ @current_release = github.release(git_repo.release_tag(repo_files.current_version))
55
+ end
56
+
57
+ def missing_assets?(release)
58
+ assets = Array(release["assets"]).map { |asset| asset["name"] }
59
+ (expected_assets - assets).any?
60
+ end
61
+
62
+ def expected_assets
63
+ ["puma-#{repo_files.current_version}.gem", "puma-#{repo_files.current_version}-java.gem"]
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaRelease
4
+ class UI
5
+ COLORS = {
6
+ info: "\e[0;32m",
7
+ warn: "\e[1;33m",
8
+ error: "\e[0;31m",
9
+ debug: "\e[0;36m",
10
+ reset: "\e[0m"
11
+ }.freeze
12
+
13
+ def info(message) = $stdout.puts(colorize(:info, message))
14
+ def warn(message) = $stdout.puts(colorize(:warn, message))
15
+ def error(message) = warn(colorize(:error, message))
16
+ def debug(message) = warn(colorize(:debug, "[DEBUG] #{message}"))
17
+
18
+ def confirm(message, default: true)
19
+ return default unless $stdin.tty?
20
+
21
+ suffix = default ? "[Y/n]" : "[y/N]"
22
+ $stdout.print("#{message} #{suffix} ")
23
+ answer = $stdin.gets.to_s.strip.downcase
24
+ return default if answer.empty?
25
+
26
+ answer.start_with?("y")
27
+ end
28
+
29
+ private
30
+
31
+ def colorize(kind, message)
32
+ color = COLORS.fetch(kind)
33
+ "#{color}==>#{COLORS.fetch(:reset)} #{message}"
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaRelease
4
+ class UpgradeGuideWriter
5
+ SYSTEM_PROMPT = <<~PROMPT.strip
6
+ You are writing the upgrade guide for a new major version of Puma, a Ruby web server gem.
7
+
8
+ Follow this structure exactly:
9
+
10
+ # Welcome to Puma X.X: Codename.
11
+
12
+ (2-3 sentences describing what this release brings and why users should be excited.)
13
+
14
+ Here's what you should do:
15
+
16
+ 1. Review the Upgrade section below to look for breaking changes that could affect you.
17
+ 2. Upgrade to version X.0 in your Gemfile and deploy.
18
+ 3. Open up a new bug issue if you find any problems.
19
+ 4. Join us in building Puma! We welcome first-timers. See [CONTRIBUTING.md](./CONTRIBUTING.md).
20
+
21
+ For a complete list of changes, see [History.md](./History.md).
22
+
23
+ ## What's New
24
+
25
+ (Describe the major user-facing features and improvements in this release. Group related
26
+ items under sub-headers if there are multiple themes. For each significant feature,
27
+ explain what it is, why it matters to the user, and how to use or configure it — include
28
+ a code example if applicable. Link to relevant PRs and issues using Markdown links to
29
+ https://github.com/puma/puma/pull/NUMBER or /issues/NUMBER. Omit pure internal
30
+ refactors, CI changes, and test-only changes.)
31
+
32
+ ## Upgrade
33
+
34
+ Check the following list to see if you're depending on any of these behaviors:
35
+
36
+ (A numbered list. Each item must be specific and actionable: name the exact DSL method,
37
+ CLI flag, environment variable, Ruby constant, or behavior that changed, and explain
38
+ exactly what the user needs to do. Cover every breaking change provided. Use inline code
39
+ formatting for config keys, env vars, CLI flags, Ruby class/method names, etc.)
40
+
41
+ Then, update your Gemfile:
42
+
43
+ `gem 'puma', '< X+1'`
44
+
45
+ Tone and style rules:
46
+ - Friendly and direct, matching the voice of the existing Puma upgrade guides.
47
+ - Do not include an image line.
48
+ - Do not invent breaking changes beyond what is provided and supported by the commit list.
49
+ - Do not include a "then update your Gemfile" line at the end of the What's New section.
50
+ - The Upgrade section must cover every breaking change in the provided list.
51
+ PROMPT
52
+
53
+ attr_reader :context, :release_range, :new_version, :breaking_changes, :codename
54
+
55
+ def initialize(context, release_range, new_version:, breaking_changes:, codename:)
56
+ @context = context
57
+ @release_range = release_range
58
+ @new_version = new_version
59
+ @breaking_changes = breaking_changes
60
+ @codename = codename
61
+ end
62
+
63
+ def call
64
+ context.ui.info("Asking #{context.agent_cmd} to write upgrade guide for #{new_version}...")
65
+ content = agent.ask_for_text(prompt, system_prompt: SYSTEM_PROMPT)
66
+ path = upgrade_guide_path
67
+ File.write(path, content.strip + "\n")
68
+ context.ui.info("Wrote upgrade guide: #{path}")
69
+ path
70
+ end
71
+
72
+ private
73
+
74
+ def upgrade_guide_path
75
+ major_minor = new_version.split(".").first(2).join(".")
76
+ File.join(context.repo_dir, "docs", "#{major_minor}-Upgrade.md")
77
+ end
78
+
79
+ def prompt
80
+ version_label = codename ? "#{new_version} (\"#{codename}\")" : new_version
81
+ next_major = new_version.split(".").first.to_i + 1
82
+
83
+ breaking_section = if breaking_changes.any?
84
+ "The following breaking changes have been identified for this release:\n\n" +
85
+ breaking_changes.map { |c| "- #{c}" }.join("\n")
86
+ else
87
+ "No specific breaking changes were pre-identified by the version recommender, " \
88
+ "but this is a major version bump. Review the commits carefully for anything " \
89
+ "that could require user action when upgrading."
90
+ end
91
+
92
+ <<~PROMPT
93
+ Write the upgrade guide for Puma #{version_label}.
94
+
95
+ The next major version after this will be #{next_major}, so the Gemfile constraint
96
+ at the end of the Upgrade section should read: `gem 'puma', '< #{next_major}'`
97
+
98
+ #{breaking_section}
99
+
100
+ #{release_range.to_prompt_context}
101
+ PROMPT
102
+ end
103
+
104
+ def agent = @agent ||= AgentClient.new(context)
105
+ end
106
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaRelease
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaRelease
4
+ class VersionRecommender
5
+ SYSTEM_PROMPT = <<~PROMPT.strip
6
+ You are deciding the semantic version bump for the next Puma release.
7
+
8
+ The 'breaking change' label on a PR means a major bump is DEFINITELY required. However,
9
+ the absence of that label does not mean a major bump isn't warranted — it just means no
10
+ one explicitly flagged it. You must independently assess every PR and commit for potential
11
+ breaking changes regardless of labels.
12
+
13
+ A breaking change is anything that could require users to update their code, configuration,
14
+ or deployment when upgrading. Be expansive: consider changes to public APIs, behavior
15
+ changes in existing options or hooks, changes to default values, removed or renamed
16
+ configuration, changes to supported Ruby or platform versions, changes to the Rack/HTTP
17
+ interface, changes to how signals are handled, changes to logging or error output format,
18
+ changes to gem dependencies, and any other change a user might feel when upgrading.
19
+
20
+ ## Puma's public API
21
+
22
+ The following are public API surfaces. Changes that alter their behavior, shape, or
23
+ availability are potentially breaking and must be flagged.
24
+
25
+ **HTTP→Rack env mapping.** For the same HTTP input bytes, Puma must produce the same
26
+ Rack env. This covers REQUEST_METHOD, PATH_INFO, QUERY_STRING, CONTENT_TYPE,
27
+ CONTENT_LENGTH, HTTP_* headers, SERVER_NAME, SERVER_PORT, SERVER_PROTOCOL, REMOTE_ADDR,
28
+ GATEWAY_INTERFACE, etc.
29
+
30
+ **Puma-specific Rack env extensions.** puma.socket, puma.peercert, and puma.config.
31
+ Also the standard Rack extensions Puma populates: rack.hijack?, rack.hijack,
32
+ rack.after_reply, rack.response_finished, rack.early_hints.
33
+
34
+ **Configuration DSL.** All methods available in puma.rb / Puma.configure blocks,
35
+ including lifecycle hooks: before_fork, after_booted, before_worker_boot,
36
+ before_worker_shutdown, after_worker_fork, after_worker_shutdown, before_restart,
37
+ before_thread_start, before_thread_exit, out_of_band, lowlevel_error_handler. Changes
38
+ to hook signatures or timing are breaking. Changes to default values of any option
39
+ (thread counts, timeouts, etc.) are also breaking.
40
+
41
+ **Deprecated hook aliases.** The old-style hooks (on_booted, on_restart, on_stopped,
42
+ on_worker_boot, on_worker_fork, on_worker_shutdown, on_refork, on_thread_start,
43
+ on_thread_exit) are deprecated but still supported. Removing them counts as a breaking
44
+ change even though they are deprecated.
45
+
46
+ **CLI interface.** All flags to the puma binary. Adding flags is a new feature (minor);
47
+ removing or changing the behavior of existing flags is breaking.
48
+
49
+ **Plugin interface.** The contract for writing a plugin: Plugin.create, the config(dsl)
50
+ hook receiving the DSL object, and the start(launcher) hook. Changes to what DSL and
51
+ Launcher expose to plugins are breaking for plugin authors.
52
+
53
+ **Control server REST API.** The HTTP interface exposed via activate_control_app:
54
+ endpoints /stop, /halt, /restart, /phased-restart, /refork, /stats, /gc, /gc-stats,
55
+ /thread-backtraces, /status, and their response formats.
56
+
57
+ **pumactl CLI.** The pumactl command and its available subcommands. Operators use this
58
+ in deploy scripts and process supervisors.
59
+
60
+ **State file format.** The fields written to the state file: pid, control_url,
61
+ control_auth_token, running_from. Tools that restart or monitor Puma read this.
62
+
63
+ **Puma.stats / Puma.stats_hash output shape.** The structure of the stats JSON.
64
+ Monitoring integrations parse specific fields; removing or renaming them is breaking.
65
+
66
+ **Signal behavior.** How Puma responds to OS signals (TERM, INT, USR1, USR2, HUP) in
67
+ both single and cluster mode.
68
+
69
+ **Supported Ruby and platform versions.** Dropping support for a Ruby version or
70
+ platform is breaking for users on that version.
71
+
72
+ ## Not part of Puma's public API
73
+
74
+ The following are implementation details. Changes here are not breaking on their own.
75
+
76
+ - Puma::Server (the Ruby class and its API)
77
+ - Parser classes (Puma::HttpParser, etc.) — the Ruby class API is internal; only the
78
+ HTTP parsing behavior visible in the Rack env is public
79
+ - Puma::Launcher, Puma::Runner, Puma::Worker, Puma::ThreadPool, Puma::Reactor,
80
+ Puma::Client
81
+ - Puma::Configuration as a Ruby class (the DSL behavior is public; the class is not)
82
+ - Internal pipe/signal constants (PIPE_WAKEUP, PIPE_BOOT, etc.)
83
+ - Log message text and format (unless explicitly documented as stable)
84
+ - Internal gem require paths
85
+
86
+ Recommend major if the 'breaking change' label is present on any PR, OR if your analysis
87
+ identifies any likely breaking changes. Otherwise recommend minor if any PR or commit
88
+ looks like a feature, new option, new hook, new capability, or other user-facing
89
+ enhancement. Otherwise recommend patch. When deciding between patch and minor, prefer minor.
90
+
91
+ Return exactly one markdown paragraph for reasoning_markdown, and include direct markdown
92
+ links to the commit URLs that drove the recommendation.
93
+
94
+ For breaking_changes, list every potential breaking change you can identify — even ones
95
+ that seem minor or unlikely to affect most users. Each entry should name the change and
96
+ briefly explain why it could break something. If you find none, return an empty array.
97
+ PROMPT
98
+
99
+ SCHEMA = {
100
+ type: "object",
101
+ required: %w[bump_type reasoning_markdown breaking_changes],
102
+ additionalProperties: false,
103
+ properties: {
104
+ bump_type: {type: "string", enum: %w[patch minor major]},
105
+ reasoning_markdown: {type: "string", minLength: 1},
106
+ breaking_changes: {
107
+ type: "array",
108
+ items: {type: "string", minLength: 1}
109
+ }
110
+ }
111
+ }.freeze
112
+
113
+ attr_reader :context, :release_range
114
+
115
+ def initialize(context, release_range)
116
+ @context = context
117
+ @release_range = release_range
118
+ end
119
+
120
+ def call
121
+ context.ui.info("Asking #{context.agent_cmd} to recommend the version bump...")
122
+ recommendation = agent.ask_for_json(prompt, system_prompt: SYSTEM_PROMPT, schema: SCHEMA)
123
+ bump_type = recommendation.fetch("bump_type")
124
+ reasoning = recommendation.fetch("reasoning_markdown").strip
125
+ raise Error, "#{context.agent_cmd} returned an invalid bump type" unless %w[patch minor major].include?(bump_type)
126
+ raise Error, "#{context.agent_cmd} returned empty bump reasoning" if reasoning.empty?
127
+ raise Error, "#{context.agent_cmd} must include commit links in its reasoning" unless reasoning.include?("https://github.com/#{context.metadata_repo}/commit/")
128
+ raise Error, "#{context.agent_cmd} must return bump reasoning as a single paragraph" if reasoning.include?("\n\n")
129
+
130
+ {
131
+ "bump_type" => bump_type,
132
+ "reasoning_markdown" => reasoning,
133
+ "breaking_changes" => recommendation.fetch("breaking_changes"),
134
+ "model_name" => agent.last_model_name || context.comment_author_model_name
135
+ }
136
+ end
137
+
138
+ private
139
+
140
+ def prompt
141
+ <<~PROMPT
142
+ Determine the semantic version bump for the next Puma release.
143
+ Return JSON that matches the provided schema.
144
+
145
+ #{release_range.to_prompt_context}
146
+ PROMPT
147
+ end
148
+
149
+ def agent = @agent ||= AgentClient.new(context)
150
+ end
151
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "puma_release/version"
4
+ require_relative "puma_release/error"
5
+ require_relative "puma_release/events"
6
+ require_relative "puma_release/ui"
7
+ require_relative "puma_release/shell"
8
+ require_relative "puma_release/options"
9
+ require_relative "puma_release/context"
10
+ require_relative "puma_release/repo_files"
11
+ require_relative "puma_release/git_repo"
12
+ require_relative "puma_release/github_client"
13
+ require_relative "puma_release/contributor_resolver"
14
+ require_relative "puma_release/release_range"
15
+ require_relative "puma_release/agent_client"
16
+ require_relative "puma_release/ci_checker"
17
+ require_relative "puma_release/version_recommender"
18
+ require_relative "puma_release/changelog_validator"
19
+ require_relative "puma_release/changelog_generator"
20
+ require_relative "puma_release/link_reference_builder"
21
+ require_relative "puma_release/upgrade_guide_writer"
22
+ require_relative "puma_release/build_support"
23
+ require_relative "puma_release/stage_detector"
24
+ require_relative "puma_release/commands/prepare"
25
+ require_relative "puma_release/commands/build"
26
+ require_relative "puma_release/commands/github"
27
+ require_relative "puma_release/commands/run"
28
+ require_relative "puma_release/cli"