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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +114 -0
- data/exe/puma-release +7 -0
- data/lib/puma_release/agent_client.rb +163 -0
- data/lib/puma_release/build_support.rb +46 -0
- data/lib/puma_release/changelog_generator.rb +171 -0
- data/lib/puma_release/changelog_validator.rb +85 -0
- data/lib/puma_release/ci_checker.rb +108 -0
- data/lib/puma_release/cli.rb +52 -0
- data/lib/puma_release/commands/build.rb +68 -0
- data/lib/puma_release/commands/github.rb +76 -0
- data/lib/puma_release/commands/prepare.rb +178 -0
- data/lib/puma_release/commands/run.rb +51 -0
- data/lib/puma_release/context.rb +167 -0
- data/lib/puma_release/contributor_resolver.rb +52 -0
- data/lib/puma_release/error.rb +5 -0
- data/lib/puma_release/events.rb +18 -0
- data/lib/puma_release/git_repo.rb +169 -0
- data/lib/puma_release/github_client.rb +163 -0
- data/lib/puma_release/link_reference_builder.rb +49 -0
- data/lib/puma_release/options.rb +47 -0
- data/lib/puma_release/release_range.rb +69 -0
- data/lib/puma_release/repo_files.rb +85 -0
- data/lib/puma_release/shell.rb +107 -0
- data/lib/puma_release/stage_detector.rb +66 -0
- data/lib/puma_release/ui.rb +36 -0
- data/lib/puma_release/upgrade_guide_writer.rb +106 -0
- data/lib/puma_release/version.rb +5 -0
- data/lib/puma_release/version_recommender.rb +151 -0
- data/lib/puma_release.rb +28 -0
- data/test/test_helper.rb +72 -0
- data/test/unit/agent_client_test.rb +116 -0
- data/test/unit/build_command_test.rb +23 -0
- data/test/unit/build_support_test.rb +6 -0
- data/test/unit/changelog_validator_test.rb +42 -0
- data/test/unit/context_test.rb +209 -0
- data/test/unit/contributor_resolver_test.rb +47 -0
- data/test/unit/git_repo_test.rb +169 -0
- data/test/unit/github_client_test.rb +90 -0
- data/test/unit/github_command_test.rb +153 -0
- data/test/unit/options_test.rb +17 -0
- data/test/unit/prepare_test.rb +136 -0
- data/test/unit/repo_files_test.rb +119 -0
- data/test/unit/run_test.rb +32 -0
- data/test/unit/shell_test.rb +29 -0
- data/test/unit/stage_detector_test.rb +72 -0
- 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,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
|
data/lib/puma_release.rb
ADDED
|
@@ -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"
|