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,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
|
|
6
|
+
module PumaRelease
|
|
7
|
+
class Context
|
|
8
|
+
attr_reader :env, :options, :events, :ui, :shell
|
|
9
|
+
|
|
10
|
+
def initialize(options, env: ENV, events: Events.new, ui: UI.new)
|
|
11
|
+
@options = options
|
|
12
|
+
@env = env
|
|
13
|
+
@events = events
|
|
14
|
+
@ui = ui
|
|
15
|
+
@shell = Shell.new(env:, cwd: repo_dir.to_s)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def repo_dir = options.fetch(:repo_dir)
|
|
19
|
+
def metadata_repo = options.fetch(:metadata_repo)
|
|
20
|
+
def allow_unknown_ci? = options.fetch(:allow_unknown_ci)
|
|
21
|
+
def skip_ci_check? = options.fetch(:skip_ci_check, false)
|
|
22
|
+
def yes? = options.fetch(:yes)
|
|
23
|
+
def live? = options.fetch(:live, false)
|
|
24
|
+
def debug? = options.fetch(:debug, false) || env["DEBUG"] == "true"
|
|
25
|
+
def changelog_backend = env.fetch("PUMA_RELEASE_CHANGELOG_BACKEND", options.fetch(:changelog_backend))
|
|
26
|
+
def codename = options.fetch(:codename)
|
|
27
|
+
|
|
28
|
+
def base_branch
|
|
29
|
+
@base_branch ||= options[:base_branch] || shell.output("git", "rev-parse", "--abbrev-ref", "HEAD").strip
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def agent_cmd = env.fetch("AGENT_CMD", "claude")
|
|
33
|
+
TOOL_URL = "https://github.com/nateberkopec/puma-release"
|
|
34
|
+
|
|
35
|
+
def version_file = repo_dir.join("lib/puma/const.rb")
|
|
36
|
+
def history_file = repo_dir.join("History.md")
|
|
37
|
+
def security_file = repo_dir.join("SECURITY.md")
|
|
38
|
+
|
|
39
|
+
def release_repo
|
|
40
|
+
@release_repo ||= options[:release_repo] || infer_release_repo
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def agent_binary
|
|
44
|
+
shell.split(agent_cmd).first || "claude"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def github_token
|
|
48
|
+
@github_token ||= begin
|
|
49
|
+
token = env.fetch("GITHUB_TOKEN", "")
|
|
50
|
+
token.empty? ? shell.optional_output("gh", "auth", "token") : token
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def check_dependencies!(*commands)
|
|
55
|
+
missing = commands.flatten.compact.uniq.reject { |command| shell.available?(command) }
|
|
56
|
+
raise Error, "Missing required dependencies: #{missing.join(" ")}" unless missing.empty?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def comment_author_model_name(fallback: env["AGENT_MODEL_NAME"])
|
|
60
|
+
value = fallback.to_s.strip
|
|
61
|
+
return value unless value.empty?
|
|
62
|
+
|
|
63
|
+
agent_binary
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def comment_attribution(model_name)
|
|
67
|
+
"This comment was written by #{model_name} working on behalf of [puma-release](#{TOOL_URL})."
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def announce_live_mode!
|
|
71
|
+
return unless live?
|
|
72
|
+
return if @live_mode_announced
|
|
73
|
+
|
|
74
|
+
ui.warn("LIVE MODE: writes will go to #{release_repo}")
|
|
75
|
+
@live_mode_announced = true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def ensure_release_writes_allowed!
|
|
79
|
+
return if live?
|
|
80
|
+
return unless release_repo == metadata_repo
|
|
81
|
+
|
|
82
|
+
raise Error,
|
|
83
|
+
"Refusing to write release state to #{release_repo} without --live. " \
|
|
84
|
+
"Use --release-repo OWNER/REPO to target a fork, or pass --live to operate on #{metadata_repo}."
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def confirm_live_github_write!(description)
|
|
88
|
+
return true unless live?
|
|
89
|
+
return true if yes?
|
|
90
|
+
return true if ui.confirm("LIVE MODE: #{description} on GitHub for #{release_repo}. Continue?")
|
|
91
|
+
|
|
92
|
+
raise Error, "Aborted live GitHub action: #{description}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def confirm_live_git_command!(*command)
|
|
96
|
+
return true unless live?
|
|
97
|
+
return true if yes?
|
|
98
|
+
|
|
99
|
+
rendered = Shellwords.join(command)
|
|
100
|
+
return true if ui.confirm("LIVE MODE: about to run git command: #{rendered}. Continue?")
|
|
101
|
+
|
|
102
|
+
raise Error, "Aborted live git action: #{rendered}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def confirm_live_gh_command!(*command)
|
|
106
|
+
return true unless live?
|
|
107
|
+
return true if yes?
|
|
108
|
+
|
|
109
|
+
rendered = Shellwords.join(command)
|
|
110
|
+
return true if ui.confirm("LIVE MODE: about to run gh command: #{rendered}. Continue?")
|
|
111
|
+
|
|
112
|
+
raise Error, "Aborted live gh action: #{rendered}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def infer_release_repo
|
|
118
|
+
return metadata_repo if live?
|
|
119
|
+
|
|
120
|
+
preferred_fork_repo || metadata_repo
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def preferred_fork_repo
|
|
124
|
+
candidates = github_remotes.reject { |_remote, repo| repo == metadata_repo }
|
|
125
|
+
return candidates.first&.last if candidates.one?
|
|
126
|
+
|
|
127
|
+
login_match = candidates.find { |_remote, repo| repo_owner(repo) == github_login }
|
|
128
|
+
return login_match.last if login_match
|
|
129
|
+
|
|
130
|
+
origin_match = candidates.find { |remote, _repo| remote == "origin" }
|
|
131
|
+
origin_match&.last
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def github_login
|
|
135
|
+
return @github_login if defined?(@github_login)
|
|
136
|
+
|
|
137
|
+
@github_login = fetch_github_login
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def repo_owner(repo)
|
|
141
|
+
repo.split("/", 2).first
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def fetch_github_login
|
|
145
|
+
return nil unless shell.available?("gh")
|
|
146
|
+
|
|
147
|
+
result = shell.run("gh", "api", "user", allow_failure: true)
|
|
148
|
+
return nil unless result.success?
|
|
149
|
+
|
|
150
|
+
login = JSON.parse(result.stdout).fetch("login", "").strip
|
|
151
|
+
login.empty? ? nil : login
|
|
152
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def github_remotes
|
|
157
|
+
shell.output("git", "remote").lines(chomp: true).filter_map do |remote|
|
|
158
|
+
repo = github_repo_from_url(shell.output("git", "remote", "get-url", remote).strip)
|
|
159
|
+
repo ? [remote, repo] : nil
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def github_repo_from_url(url)
|
|
164
|
+
url[%r{github\.com[:/]([^/]+/[^/.]+?)(?:\.git)?$}, 1]
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PumaRelease
|
|
4
|
+
class ContributorResolver
|
|
5
|
+
attr_reader :context, :git_repo, :github
|
|
6
|
+
|
|
7
|
+
def initialize(context, git_repo: GitRepo.new(context), github: GitHubClient.new(context))
|
|
8
|
+
@context = context
|
|
9
|
+
@git_repo = git_repo
|
|
10
|
+
@github = github
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def codename_earner(tag)
|
|
14
|
+
contributor = top_contributors_since(tag).first
|
|
15
|
+
return nil unless contributor
|
|
16
|
+
|
|
17
|
+
contributor.merge(login: resolve_login(tag, contributor))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def top_contributors_since(tag)
|
|
21
|
+
git_repo.top_contributors_since_with_email(tag).map do |line|
|
|
22
|
+
count, name, email = line.match(/^\s*(\d+)\s+(.+?)\s+<([^>]+)>$/)&.captures
|
|
23
|
+
next unless count
|
|
24
|
+
|
|
25
|
+
{count: count.to_i, name:, email:}
|
|
26
|
+
end.compact
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def resolve_login(tag, contributor)
|
|
32
|
+
logins = commits_for(tag, contributor).filter_map { |commit| github.commit_author_login(context.metadata_repo, commit.fetch(:sha)) }
|
|
33
|
+
return most_common(logins) unless logins.empty?
|
|
34
|
+
|
|
35
|
+
login_from_noreply_email(contributor.fetch(:email))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def commits_for(tag, contributor)
|
|
39
|
+
git_repo.commit_authors_since(tag).select do |commit|
|
|
40
|
+
commit.fetch(:name) == contributor.fetch(:name) && commit.fetch(:email) == contributor.fetch(:email)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def most_common(values)
|
|
45
|
+
values.tally.max_by { |_value, count| count }&.first
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def login_from_noreply_email(email)
|
|
49
|
+
email[%r{\A(?:\d+\+)?([^@]+)@users\.noreply\.github\.com\z}, 1]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PumaRelease
|
|
4
|
+
class Events
|
|
5
|
+
def initialize
|
|
6
|
+
@subscribers = Hash.new { |hash, key| hash[key] = [] }
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def subscribe(name = :all, &block)
|
|
10
|
+
@subscribers[name] << block
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def publish(name, payload = {})
|
|
14
|
+
@subscribers.fetch(:all, []).each { |subscriber| subscriber.call(name, payload) }
|
|
15
|
+
@subscribers.fetch(name, []).each { |subscriber| subscriber.call(name, payload) }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PumaRelease
|
|
4
|
+
class GitRepo
|
|
5
|
+
attr_reader :context
|
|
6
|
+
|
|
7
|
+
def initialize(context)
|
|
8
|
+
@context = context
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def current_branch = shell.output("git", "rev-parse", "--abbrev-ref", "HEAD").strip
|
|
12
|
+
def clean? = shell.output("git", "status", "--porcelain").strip.empty?
|
|
13
|
+
def head_sha = shell.output("git", "rev-parse", "HEAD").strip
|
|
14
|
+
def commits_since(tag) = shell.output("git", "rev-list", "--count", "#{tag}..HEAD").strip.to_i
|
|
15
|
+
|
|
16
|
+
def ensure_clean_base!
|
|
17
|
+
base = context.base_branch
|
|
18
|
+
raise Error, "Must be on '#{base}' branch (currently on '#{current_branch}')" unless current_branch == base
|
|
19
|
+
raise Error, "Working directory not clean. Commit or stash first." unless clean?
|
|
20
|
+
|
|
21
|
+
run_git!("fetch", metadata_remote, "--quiet")
|
|
22
|
+
remote_sha = shell.output("git", "rev-parse", "#{metadata_remote}/#{base}").strip
|
|
23
|
+
raise Error, "Local #{base} differs from #{metadata_remote}/#{base}. Pull or push first." unless head_sha == remote_sha
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def last_tag
|
|
27
|
+
tags = shell.output("git", "tag", "--sort=-v:refname", "--merged", "HEAD").lines(chomp: true)
|
|
28
|
+
tags.find { |tag| tag.match?(/^v\d/) } || raise(Error, "Could not determine last release tag")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def bump_version(version, bump_type)
|
|
32
|
+
major, minor, patch = version.split(".").map(&:to_i)
|
|
33
|
+
return "#{major + 1}.0.0" if bump_type == "major"
|
|
34
|
+
return "#{major}.#{minor + 1}.0" if bump_type == "minor"
|
|
35
|
+
return "#{major}.#{minor}.#{patch + 1}" if bump_type == "patch"
|
|
36
|
+
|
|
37
|
+
raise Error, "Unknown bump type: #{bump_type}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def top_contributors_since(tag)
|
|
41
|
+
shell.output("git", "shortlog", "-s", "-n", "--no-merges", "#{tag}..HEAD").lines(chomp: true)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def top_contributors_since_with_email(tag)
|
|
45
|
+
shell.output("git", "shortlog", "-s", "-n", "-e", "--no-merges", "#{tag}..HEAD").lines(chomp: true)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def commit_authors_since(tag)
|
|
49
|
+
shell.output("git", "log", "--format=%H%x09%aN%x09%aE", "#{tag}..HEAD").lines(chomp: true).map do |line|
|
|
50
|
+
sha, name, email = line.split("\t", 3)
|
|
51
|
+
{sha:, name:, email:}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def release_tag(version) = "v#{version}"
|
|
56
|
+
def proposal_tag(version) = "#{release_tag(version)}-proposal"
|
|
57
|
+
|
|
58
|
+
def checkout_release_branch!(branch)
|
|
59
|
+
run_git!("checkout", "-b", branch)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def commit_release!(version, extra_files: [])
|
|
63
|
+
run_git!("add", context.version_file.to_s, context.history_file.to_s, *extra_files.map(&:to_s))
|
|
64
|
+
run_git!("commit", "-S", "-m", "Release v#{version}")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def push_branch!(branch)
|
|
68
|
+
command = ["push"]
|
|
69
|
+
command << "-u" if release_remote
|
|
70
|
+
command += [release_push_target, branch]
|
|
71
|
+
run_git!(*command)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def remote_tag_sha(tag, repo: context.release_repo)
|
|
75
|
+
refs = shell.output("git", "ls-remote", "--tags", remote_target_for(repo), "refs/tags/#{tag}", "refs/tags/#{tag}^{}").lines(chomp: true)
|
|
76
|
+
peeled = refs.find { |line| line.end_with?("refs/tags/#{tag}^{}") }
|
|
77
|
+
direct = refs.find { |line| line.end_with?("refs/tags/#{tag}") }
|
|
78
|
+
(peeled || direct).to_s.split.first.to_s
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def remote_tag_object_sha(tag, repo: context.release_repo)
|
|
82
|
+
shell.output("git", "ls-remote", "--tags", remote_target_for(repo), "refs/tags/#{tag}").split.first.to_s
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def local_tag_sha(tag)
|
|
86
|
+
shell.optional_output("git", "rev-parse", "-q", "--verify", "refs/tags/#{tag}^{commit}")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def local_tag_object_sha(tag)
|
|
90
|
+
shell.optional_output("git", "rev-parse", "-q", "--verify", "refs/tags/#{tag}")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def create_signed_tag!(tag, message: "Release #{tag}")
|
|
94
|
+
run_git!("tag", "-s", tag, "-m", message)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def local_tag_signed?(tag)
|
|
98
|
+
return false if local_tag_sha(tag).empty?
|
|
99
|
+
|
|
100
|
+
shell.output("git", "cat-file", "-p", "refs/tags/#{tag}").include?("-----BEGIN PGP SIGNATURE-----")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def ensure_release_tag_pushed!(tag)
|
|
104
|
+
head = head_sha
|
|
105
|
+
local = local_tag_sha(tag)
|
|
106
|
+
remote = remote_tag_sha(tag)
|
|
107
|
+
|
|
108
|
+
raise Error, "Remote tag #{tag} already exists at #{remote}, not HEAD #{head}." if !remote.empty? && remote != head
|
|
109
|
+
raise Error, "Local tag #{tag} already exists at #{local}, not HEAD #{head}." if !local.empty? && local != head
|
|
110
|
+
raise Error, "Local tag #{tag} exists at #{head} but is not GPG-signed." if !local.empty? && !local_tag_signed?(tag)
|
|
111
|
+
|
|
112
|
+
create_signed_tag!(tag) if local.empty?
|
|
113
|
+
|
|
114
|
+
local_object = local_tag_object_sha(tag)
|
|
115
|
+
remote_object = remote_tag_object_sha(tag)
|
|
116
|
+
return if !remote_object.empty? && remote_object == local_object
|
|
117
|
+
|
|
118
|
+
raise Error, "Remote tag #{tag} already exists but does not match the local signed tag." unless remote_object.empty?
|
|
119
|
+
|
|
120
|
+
run_git!("push", release_push_target, tag)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def delete_local_tag!(tag, allow_failure: false)
|
|
124
|
+
run_git!("tag", "-d", tag, allow_failure:)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def shell = context.shell
|
|
130
|
+
|
|
131
|
+
def run_git!(*command, **options)
|
|
132
|
+
context.confirm_live_git_command!("git", *command) if context.respond_to?(:confirm_live_git_command!)
|
|
133
|
+
shell.run("git", *command, **options)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def metadata_remote
|
|
137
|
+
remote_name_for(context.metadata_repo) || "origin"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def release_remote
|
|
141
|
+
remote_name_for(context.release_repo)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def release_push_target
|
|
145
|
+
release_remote || github_url_for(context.release_repo)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def remote_target_for(repo)
|
|
149
|
+
remote_name_for(repo) || github_url_for(repo)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def remote_name_for(repo)
|
|
153
|
+
shell.output("git", "remote").lines(chomp: true).find do |remote|
|
|
154
|
+
github_repo_from_url(shell.output("git", "remote", "get-url", remote).strip) == repo
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def github_url_for(repo)
|
|
159
|
+
origin_url = shell.output("git", "remote", "get-url", "origin").strip
|
|
160
|
+
return "git@github.com:#{repo}.git" if origin_url.match?(%r{\A(?:git@github\.com:|ssh://git@github\.com/)})
|
|
161
|
+
|
|
162
|
+
"https://github.com/#{repo}.git"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def github_repo_from_url(url)
|
|
166
|
+
url[%r{github\.com[:/]([^/]+/[^/.]+?)(?:\.git)?$}, 1]
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
module PumaRelease
|
|
7
|
+
class GitHubClient
|
|
8
|
+
attr_reader :context
|
|
9
|
+
|
|
10
|
+
def initialize(context)
|
|
11
|
+
@context = context
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def commit_pulls(repo, sha)
|
|
15
|
+
json("gh", "api", "repos/#{repo}/commits/#{sha}/pulls") || []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def commit_author_login(repo, sha)
|
|
19
|
+
commit = json("gh", "api", "repos/#{repo}/commits/#{sha}")
|
|
20
|
+
login = commit&.dig("author", "login") || commit&.dig("committer", "login")
|
|
21
|
+
return login if login && !login.empty?
|
|
22
|
+
|
|
23
|
+
commit_pulls(repo, sha).first&.dig("user", "login")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def pr(number, repo: context.release_repo)
|
|
27
|
+
json("gh", "pr", "view", number.to_s, "--repo", repo, "--json", "number,title,url,state,mergedAt,author,labels,headRefName")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def issue(number, repo: context.metadata_repo)
|
|
31
|
+
json("gh", "issue", "view", number.to_s, "--repo", repo, "--json", "number,title,url,closedAt,author")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def user(login)
|
|
35
|
+
json("gh", "api", "users/#{login}")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def open_release_pr
|
|
39
|
+
owner = context.release_repo.split("/").first
|
|
40
|
+
prs = json(
|
|
41
|
+
"gh", "pr", "list", "--repo", context.release_repo,
|
|
42
|
+
"--state", "open",
|
|
43
|
+
"--search", "head:#{owner}:release-v",
|
|
44
|
+
"--json", "number,title,url,headRefName"
|
|
45
|
+
) || []
|
|
46
|
+
prs.find { |pr| pr.fetch("headRefName", "").start_with?("release-v") }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def create_release_pr(title, branch, body: "")
|
|
50
|
+
output_gh!(
|
|
51
|
+
"pr", "create",
|
|
52
|
+
"--repo", context.release_repo,
|
|
53
|
+
"--base", context.base_branch,
|
|
54
|
+
"--head", branch,
|
|
55
|
+
"--title", title,
|
|
56
|
+
"--body", body
|
|
57
|
+
).strip
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def update_pr_body(pr_url, body)
|
|
61
|
+
run_gh!("pr", "edit", pr_url, "--body", body)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def comment_on_pr(pr_url, body)
|
|
65
|
+
run_gh!("pr", "comment", pr_url, "--body", body)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def release(tag)
|
|
69
|
+
json("gh", "release", "view", tag, "--repo", context.release_repo, "--json", "tagName,name,isDraft,body,url,assets,targetCommitish")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def retag_release(old_tag, new_tag, target: nil)
|
|
73
|
+
release_id = release_id(old_tag)
|
|
74
|
+
command = ["api", "-X", "PATCH", "repos/#{context.release_repo}/releases/#{release_id}", "-f", "tag_name=#{new_tag}"]
|
|
75
|
+
command += ["-f", "target_commitish=#{target}"] if target
|
|
76
|
+
run_gh!(*command)
|
|
77
|
+
release(new_tag)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def delete_release(tag, allow_failure: false)
|
|
81
|
+
release_id = release_id(tag, allow_failure:)
|
|
82
|
+
return false unless release_id
|
|
83
|
+
|
|
84
|
+
run_gh!("api", "-X", "DELETE", "repos/#{context.release_repo}/releases/#{release_id}", allow_failure:)
|
|
85
|
+
true
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def delete_tag_ref(tag, allow_failure: false)
|
|
89
|
+
run_gh!("api", "-X", "DELETE", "repos/#{context.release_repo}/git/refs/tags/#{tag}", allow_failure:)
|
|
90
|
+
true
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def create_release(tag, body, title: tag, draft: true, target: nil)
|
|
94
|
+
with_notes_file(body) do |path|
|
|
95
|
+
command = ["release", "create", tag, "--repo", context.release_repo, "--title", title, "--notes-file", path]
|
|
96
|
+
command += ["--target", target] if target
|
|
97
|
+
command << "--draft" if draft
|
|
98
|
+
run_gh!(*command)
|
|
99
|
+
end
|
|
100
|
+
release(tag)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def edit_release_notes(tag, body)
|
|
104
|
+
with_notes_file(body) { |path| run_gh!("release", "edit", tag, "--repo", context.release_repo, "--notes-file", path) }
|
|
105
|
+
release(tag)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def edit_release_target(tag, target)
|
|
109
|
+
run_gh!("release", "edit", tag, "--repo", context.release_repo, "--target", target)
|
|
110
|
+
release(tag)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def edit_release_title(tag, title)
|
|
114
|
+
run_gh!("release", "edit", tag, "--repo", context.release_repo, "--title", title)
|
|
115
|
+
release(tag)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def publish_release(tag)
|
|
119
|
+
run_gh!("release", "edit", tag, "--repo", context.release_repo, "--draft=false")
|
|
120
|
+
release(tag)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def upload_release_assets(tag, *paths)
|
|
124
|
+
run_gh!("release", "upload", tag, "--repo", context.release_repo, "--clobber", *paths)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def json(*command)
|
|
130
|
+
result = context.shell.run(*command, allow_failure: true)
|
|
131
|
+
return nil unless result.success?
|
|
132
|
+
|
|
133
|
+
body = result.stdout.strip
|
|
134
|
+
body.empty? ? {} : JSON.parse(body)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def run_gh!(*command, **options)
|
|
138
|
+
context.confirm_live_gh_command!("gh", *command) if context.respond_to?(:confirm_live_gh_command!)
|
|
139
|
+
context.shell.run("gh", *command, **options)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def output_gh!(*command, **options)
|
|
143
|
+
context.confirm_live_gh_command!("gh", *command) if context.respond_to?(:confirm_live_gh_command!)
|
|
144
|
+
context.shell.output("gh", *command, **options)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def release_id(tag, allow_failure: false)
|
|
148
|
+
payload = json("gh", "api", "repos/#{context.release_repo}/releases/tags/#{tag}")
|
|
149
|
+
return payload&.fetch("id", nil) if payload
|
|
150
|
+
return nil if allow_failure
|
|
151
|
+
|
|
152
|
+
raise Error, "Could not find release for tag #{tag}"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def with_notes_file(body)
|
|
156
|
+
Tempfile.create("puma-release-notes") do |file|
|
|
157
|
+
file.write(body)
|
|
158
|
+
file.flush
|
|
159
|
+
yield file.path
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PumaRelease
|
|
4
|
+
class LinkReferenceBuilder
|
|
5
|
+
attr_reader :context
|
|
6
|
+
|
|
7
|
+
def initialize(context)
|
|
8
|
+
@context = context
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def build(changelog)
|
|
12
|
+
numbers = changelog.scan(/\[#(\d+)\]/).flatten.map(&:to_i).uniq.sort.reverse
|
|
13
|
+
existing = context.history_file.read
|
|
14
|
+
numbers.filter_map { |number| reference_for(number, existing) }.join("\n")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def reference_for(number, existing)
|
|
20
|
+
return if existing.match?(/^\[##{number}\]:/)
|
|
21
|
+
|
|
22
|
+
context.ui.info(" Looking up ##{number}...")
|
|
23
|
+
pr = github.pr(number, repo: context.metadata_repo)
|
|
24
|
+
return pr_reference(number, pr) if pr
|
|
25
|
+
|
|
26
|
+
issue = github.issue(number, repo: context.metadata_repo)
|
|
27
|
+
return issue_reference(number, issue) if issue
|
|
28
|
+
|
|
29
|
+
context.ui.warn("Could not look up ##{number}")
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def pr_reference(number, pr)
|
|
34
|
+
login = pr.dig("author", "login")
|
|
35
|
+
author = github.user(login)&.fetch("name", nil).to_s
|
|
36
|
+
author = login if author.empty?
|
|
37
|
+
merged_at = pr.fetch("mergedAt", "").split("T").first
|
|
38
|
+
"[##{number}]:https://github.com/#{context.metadata_repo}/pull/#{number} \"PR by #{author}, merged #{merged_at}\""
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def issue_reference(number, issue)
|
|
42
|
+
login = issue.dig("author", "login")
|
|
43
|
+
closed_at = issue.fetch("closedAt", "").split("T").first
|
|
44
|
+
"[##{number}]:https://github.com/#{context.metadata_repo}/issues/#{number} \"Issue by @#{login}, closed #{closed_at}\""
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def github = @github ||= GitHubClient.new(context)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module PumaRelease
|
|
6
|
+
class Options
|
|
7
|
+
DEFAULTS = {
|
|
8
|
+
command: "run",
|
|
9
|
+
repo_dir: Dir.pwd,
|
|
10
|
+
metadata_repo: "puma/puma",
|
|
11
|
+
changelog_backend: "auto",
|
|
12
|
+
allow_unknown_ci: false,
|
|
13
|
+
skip_ci_check: false,
|
|
14
|
+
yes: false,
|
|
15
|
+
live: false,
|
|
16
|
+
debug: false,
|
|
17
|
+
codename: nil,
|
|
18
|
+
base_branch: nil
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def self.parse(argv)
|
|
22
|
+
options = DEFAULTS.dup
|
|
23
|
+
parser = OptionParser.new do |opts|
|
|
24
|
+
opts.banner = "Usage: puma-release [options] [command]"
|
|
25
|
+
|
|
26
|
+
opts.on("--repo-dir PATH", "Path to the Puma checkout") { |value| options[:repo_dir] = value }
|
|
27
|
+
opts.on("--release-repo OWNER/REPO", "Repo for PRs, tags, and releases") { |value| options[:release_repo] = value }
|
|
28
|
+
opts.on("--metadata-repo OWNER/REPO", "Repo for PR metadata and commit links") { |value| options[:metadata_repo] = value }
|
|
29
|
+
opts.on("--live", "Allow writes to the metadata repo for the real release") { options[:live] = true }
|
|
30
|
+
opts.on("--allow-unknown-ci", "Proceed when CI status cannot be determined") { options[:allow_unknown_ci] = true }
|
|
31
|
+
opts.on("--skip-ci-check", "Skip the CI check entirely during prepare") { options[:skip_ci_check] = true }
|
|
32
|
+
opts.on("--changelog-backend NAME", "auto, agent, or communique") { |value| options[:changelog_backend] = value }
|
|
33
|
+
opts.on("-y", "--yes", "Skip interactive confirmations") { options[:yes] = true }
|
|
34
|
+
opts.on("--debug", "Enable debug logging") { options[:debug] = true }
|
|
35
|
+
opts.on("--codename NAME", "Set the release codename directly") { |value| options[:codename] = value }
|
|
36
|
+
opts.on("--base-branch BRANCH", "Base branch for the release (default: current branch)") { |value| options[:base_branch] = value }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
remaining = parser.parse(argv)
|
|
40
|
+
options[:command] = remaining.first if remaining.first
|
|
41
|
+
options[:repo_dir] = Pathname(options[:repo_dir]).expand_path
|
|
42
|
+
options
|
|
43
|
+
rescue OptionParser::ParseError => e
|
|
44
|
+
raise Error, e.message
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|