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,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ class GitRepoTest < Minitest::Test
6
+ def test_push_branch_uses_release_repo_remote_when_present
7
+ shell = FakeShell.new(
8
+ {
9
+ ["git", "remote"] => "origin\nmine\n",
10
+ ["git", "remote", "get-url", "origin"] => "https://github.com/puma/puma.git\n",
11
+ ["git", "remote", "get-url", "mine"] => "git@github.com:nateberkopec/puma.git\n"
12
+ }
13
+ )
14
+
15
+ context = OpenStruct.new(shell:, release_repo: "nateberkopec/puma", metadata_repo: "puma/puma")
16
+
17
+ PumaRelease::GitRepo.new(context).push_branch!("release-v7.3.0")
18
+
19
+ assert_includes shell.commands, ["git", "push", "-u", "mine", "release-v7.3.0"]
20
+ end
21
+
22
+ def test_push_branch_falls_back_to_release_repo_url_when_remote_is_missing
23
+ shell = FakeShell.new(
24
+ {
25
+ ["git", "remote"] => "origin\n",
26
+ ["git", "remote", "get-url", "origin"] => "git@github.com:puma/puma.git\n"
27
+ }
28
+ )
29
+
30
+ context = OpenStruct.new(shell:, release_repo: "nateberkopec/puma", metadata_repo: "puma/puma")
31
+
32
+ PumaRelease::GitRepo.new(context).push_branch!("release-v7.3.0")
33
+
34
+ assert_includes shell.commands, ["git", "push", "git@github.com:nateberkopec/puma.git", "release-v7.3.0"]
35
+ end
36
+
37
+ def test_push_branch_confirms_the_full_git_command_in_live_mode
38
+ shell = FakeShell.new(
39
+ {
40
+ ["git", "remote"] => "origin\nmine\n",
41
+ ["git", "remote", "get-url", "origin"] => "https://github.com/puma/puma.git\n",
42
+ ["git", "remote", "get-url", "mine"] => "git@github.com:nateberkopec/puma.git\n"
43
+ }
44
+ )
45
+ confirmations = []
46
+ context = OpenStruct.new(shell:, release_repo: "nateberkopec/puma", metadata_repo: "puma/puma")
47
+ context.define_singleton_method(:confirm_live_git_command!) { |*command| confirmations << command }
48
+
49
+ PumaRelease::GitRepo.new(context).push_branch!("release-v7.3.0")
50
+
51
+ assert_equal [["git", "push", "-u", "mine", "release-v7.3.0"]], confirmations
52
+ end
53
+
54
+ def test_ensure_clean_base_prefers_metadata_repo_remote_when_present
55
+ shell = FakeShell.new(
56
+ {
57
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"] => "main\n",
58
+ ["git", "status", "--porcelain"] => "",
59
+ ["git", "remote"] => "origin\nupstream\n",
60
+ ["git", "remote", "get-url", "origin"] => "git@github.com:nateberkopec/puma.git\n",
61
+ ["git", "remote", "get-url", "upstream"] => "https://github.com/puma/puma.git\n",
62
+ ["git", "rev-parse", "upstream/main"] => "abc123\n",
63
+ ["git", "rev-parse", "HEAD"] => "abc123\n"
64
+ }
65
+ )
66
+
67
+ context = OpenStruct.new(shell:, release_repo: "nateberkopec/puma", metadata_repo: "puma/puma", base_branch: "main")
68
+
69
+ PumaRelease::GitRepo.new(context).ensure_clean_base!
70
+
71
+ assert_includes shell.commands, ["git", "fetch", "upstream", "--quiet"]
72
+ end
73
+
74
+ def test_commit_release_creates_a_signed_commit
75
+ temp_repo do |repo|
76
+ version_file = repo.join("lib/puma/const.rb")
77
+ history_file = repo.join("History.md")
78
+ version_file.write("")
79
+ history_file.write("")
80
+ shell = FakeShell.new
81
+ context = OpenStruct.new(shell:, version_file:, history_file:)
82
+
83
+ PumaRelease::GitRepo.new(context).commit_release!("7.3.0")
84
+
85
+ assert_includes shell.commands, ["git", "commit", "-S", "-m", "Release v7.3.0"]
86
+ end
87
+ end
88
+
89
+ def test_ensure_release_tag_pushed_creates_a_signed_tag
90
+ shell = FakeShell.new(
91
+ {
92
+ ["git", "rev-parse", "HEAD"] => "abc123\n",
93
+ ["git", "ls-remote", "--tags", "mine", "refs/tags/v7.3.0", "refs/tags/v7.3.0^{}"] => "",
94
+ ["git", "remote"] => "origin\nmine\n",
95
+ ["git", "remote", "get-url", "origin"] => "https://github.com/puma/puma.git\n",
96
+ ["git", "remote", "get-url", "mine"] => "git@github.com:nateberkopec/puma.git\n"
97
+ }
98
+ )
99
+
100
+ context = OpenStruct.new(shell:, release_repo: "nateberkopec/puma", metadata_repo: "puma/puma")
101
+
102
+ PumaRelease::GitRepo.new(context).ensure_release_tag_pushed!("v7.3.0")
103
+
104
+ assert_includes shell.commands, ["git", "tag", "-s", "v7.3.0", "-m", "Release v7.3.0"]
105
+ end
106
+
107
+ def test_remote_tag_sha_returns_the_peeled_commit_for_signed_tags
108
+ shell = FakeShell.new(
109
+ {
110
+ ["git", "ls-remote", "--tags", "mine", "refs/tags/v7.3.0", "refs/tags/v7.3.0^{}"] => [
111
+ "deadbeef\trefs/tags/v7.3.0",
112
+ "abc123\trefs/tags/v7.3.0^{}"
113
+ ].join("\n"),
114
+ ["git", "remote"] => "origin\nmine\n",
115
+ ["git", "remote", "get-url", "origin"] => "https://github.com/puma/puma.git\n",
116
+ ["git", "remote", "get-url", "mine"] => "git@github.com:nateberkopec/puma.git\n"
117
+ }
118
+ )
119
+
120
+ context = OpenStruct.new(shell:, release_repo: "nateberkopec/puma", metadata_repo: "puma/puma")
121
+
122
+ assert_equal "abc123", PumaRelease::GitRepo.new(context).remote_tag_sha("v7.3.0")
123
+ end
124
+
125
+ def test_ensure_release_tag_pushed_rejects_an_unsigned_local_tag
126
+ shell = FakeShell.new(
127
+ {
128
+ ["git", "rev-parse", "HEAD"] => "abc123\n",
129
+ ["git", "rev-parse", "-q", "--verify", "refs/tags/v7.3.0^{commit}"] => "abc123\n",
130
+ ["git", "ls-remote", "--tags", "mine", "refs/tags/v7.3.0", "refs/tags/v7.3.0^{}"] => "",
131
+ ["git", "cat-file", "-p", "refs/tags/v7.3.0"] => "tree deadbeef\nauthor Example <example@test> 0 +0000\n\nnot signed\n",
132
+ ["git", "remote"] => "origin\nmine\n",
133
+ ["git", "remote", "get-url", "origin"] => "https://github.com/puma/puma.git\n",
134
+ ["git", "remote", "get-url", "mine"] => "git@github.com:nateberkopec/puma.git\n"
135
+ }
136
+ )
137
+
138
+ context = OpenStruct.new(shell:, release_repo: "nateberkopec/puma", metadata_repo: "puma/puma")
139
+
140
+ error = assert_raises(PumaRelease::Error) { PumaRelease::GitRepo.new(context).ensure_release_tag_pushed!("v7.3.0") }
141
+
142
+ assert_includes error.message, "is not GPG-signed"
143
+ end
144
+
145
+ def test_ensure_release_tag_pushed_rejects_a_remote_tag_that_differs_from_the_local_signed_tag
146
+ shell = FakeShell.new(
147
+ {
148
+ ["git", "rev-parse", "HEAD"] => "abc123\n",
149
+ ["git", "rev-parse", "-q", "--verify", "refs/tags/v7.3.0^{commit}"] => "abc123\n",
150
+ ["git", "rev-parse", "-q", "--verify", "refs/tags/v7.3.0"] => "localtag\n",
151
+ ["git", "ls-remote", "--tags", "mine", "refs/tags/v7.3.0", "refs/tags/v7.3.0^{}"] => [
152
+ "remotetag\trefs/tags/v7.3.0",
153
+ "abc123\trefs/tags/v7.3.0^{}"
154
+ ].join("\n"),
155
+ ["git", "ls-remote", "--tags", "mine", "refs/tags/v7.3.0"] => "remotetag\trefs/tags/v7.3.0\n",
156
+ ["git", "cat-file", "-p", "refs/tags/v7.3.0"] => "object abc123\ntype commit\ntag v7.3.0\n\n-----BEGIN PGP SIGNATURE-----\n",
157
+ ["git", "remote"] => "origin\nmine\n",
158
+ ["git", "remote", "get-url", "origin"] => "https://github.com/puma/puma.git\n",
159
+ ["git", "remote", "get-url", "mine"] => "git@github.com:nateberkopec/puma.git\n"
160
+ }
161
+ )
162
+
163
+ context = OpenStruct.new(shell:, release_repo: "nateberkopec/puma", metadata_repo: "puma/puma")
164
+
165
+ error = assert_raises(PumaRelease::Error) { PumaRelease::GitRepo.new(context).ensure_release_tag_pushed!("v7.3.0") }
166
+
167
+ assert_includes error.message, "does not match the local signed tag"
168
+ end
169
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ class GitHubClientTest < Minitest::Test
6
+ def test_edit_release_target_passes_target_to_gh
7
+ shell = FakeShell.new(
8
+ {
9
+ ["gh", "release", "edit", "v7.3.0", "--repo", "nateberkopec/puma", "--target", "abc123"] => FakeShell::Result.new(stdout: "", stderr: "", success?: true, exitstatus: 0),
10
+ ["gh", "release", "view", "v7.3.0", "--repo", "nateberkopec/puma", "--json", "tagName,name,isDraft,body,url,assets,targetCommitish"] => FakeShell::Result.new(stdout: '{"tagName":"v7.3.0","targetCommitish":"abc123"}', stderr: "", success?: true, exitstatus: 0)
11
+ }
12
+ )
13
+
14
+ confirmations = []
15
+ context = OpenStruct.new(shell:, release_repo: "nateberkopec/puma")
16
+ context.define_singleton_method(:confirm_live_gh_command!) { |*command| confirmations << command }
17
+ release = PumaRelease::GitHubClient.new(context).edit_release_target("v7.3.0", "abc123")
18
+
19
+ assert_equal [["gh", "release", "edit", "v7.3.0", "--repo", "nateberkopec/puma", "--target", "abc123"]], confirmations
20
+ assert_equal "abc123", release.fetch("targetCommitish")
21
+ end
22
+
23
+ def test_edit_release_title_passes_title_to_gh
24
+ shell = FakeShell.new(
25
+ {
26
+ ["gh", "release", "edit", "v7.3.0", "--repo", "nateberkopec/puma", "--title", "v7.3.0 - INSERT CODENAME HERE"] => FakeShell::Result.new(stdout: "", stderr: "", success?: true, exitstatus: 0),
27
+ ["gh", "release", "view", "v7.3.0", "--repo", "nateberkopec/puma", "--json", "tagName,name,isDraft,body,url,assets,targetCommitish"] => FakeShell::Result.new(stdout: '{"tagName":"v7.3.0","name":"v7.3.0 - INSERT CODENAME HERE"}', stderr: "", success?: true, exitstatus: 0)
28
+ }
29
+ )
30
+
31
+ confirmations = []
32
+ context = OpenStruct.new(shell:, release_repo: "nateberkopec/puma")
33
+ context.define_singleton_method(:confirm_live_gh_command!) { |*command| confirmations << command }
34
+ release = PumaRelease::GitHubClient.new(context).edit_release_title("v7.3.0", "v7.3.0 - INSERT CODENAME HERE")
35
+
36
+ assert_equal [["gh", "release", "edit", "v7.3.0", "--repo", "nateberkopec/puma", "--title", "v7.3.0 - INSERT CODENAME HERE"]], confirmations
37
+ assert_equal "v7.3.0 - INSERT CODENAME HERE", release.fetch("name")
38
+ end
39
+
40
+ def test_create_release_pr_confirms_before_writing
41
+ shell = FakeShell.new(
42
+ {
43
+ ["gh", "pr", "create", "--repo", "puma/puma", "--base", "main", "--head", "release-v7.3.0", "--title", "Release v7.3.0", "--body", "compare"] => "https://github.com/puma/puma/pull/1\n"
44
+ }
45
+ )
46
+
47
+ confirmations = []
48
+ context = OpenStruct.new(shell:, release_repo: "puma/puma", base_branch: "main")
49
+ context.define_singleton_method(:confirm_live_gh_command!) { |*command| confirmations << command }
50
+
51
+ pr_url = PumaRelease::GitHubClient.new(context).create_release_pr("Release v7.3.0", "release-v7.3.0", body: "compare")
52
+
53
+ assert_equal [["gh", "pr", "create", "--repo", "puma/puma", "--base", "main", "--head", "release-v7.3.0", "--title", "Release v7.3.0", "--body", "compare"]], confirmations
54
+ assert_equal "https://github.com/puma/puma/pull/1", pr_url
55
+ end
56
+
57
+ def test_retag_release_updates_the_release_tag_name_via_api
58
+ shell = FakeShell.new(
59
+ {
60
+ ["gh", "api", "repos/nateberkopec/puma/releases/tags/v7.3.0-proposal"] => FakeShell::Result.new(stdout: '{"id":123}', stderr: "", success?: true, exitstatus: 0),
61
+ ["gh", "api", "-X", "PATCH", "repos/nateberkopec/puma/releases/123", "-f", "tag_name=v7.3.0", "-f", "target_commitish=abc123"] => FakeShell::Result.new(stdout: "", stderr: "", success?: true, exitstatus: 0),
62
+ ["gh", "release", "view", "v7.3.0", "--repo", "nateberkopec/puma", "--json", "tagName,name,isDraft,body,url,assets,targetCommitish"] => FakeShell::Result.new(stdout: '{"tagName":"v7.3.0","targetCommitish":"abc123"}', stderr: "", success?: true, exitstatus: 0)
63
+ }
64
+ )
65
+
66
+ confirmations = []
67
+ context = OpenStruct.new(shell:, release_repo: "nateberkopec/puma")
68
+ context.define_singleton_method(:confirm_live_gh_command!) { |*command| confirmations << command }
69
+
70
+ release = PumaRelease::GitHubClient.new(context).retag_release("v7.3.0-proposal", "v7.3.0", target: "abc123")
71
+
72
+ assert_equal [["gh", "api", "-X", "PATCH", "repos/nateberkopec/puma/releases/123", "-f", "tag_name=v7.3.0", "-f", "target_commitish=abc123"]], confirmations
73
+ assert_equal "v7.3.0", release.fetch("tagName")
74
+ end
75
+
76
+ def test_delete_tag_ref_deletes_a_remote_tag_ref_via_api
77
+ shell = FakeShell.new(
78
+ {
79
+ ["gh", "api", "-X", "DELETE", "repos/nateberkopec/puma/git/refs/tags/v7.3.0-proposal"] => FakeShell::Result.new(stdout: "", stderr: "", success?: true, exitstatus: 0)
80
+ }
81
+ )
82
+
83
+ confirmations = []
84
+ context = OpenStruct.new(shell:, release_repo: "nateberkopec/puma")
85
+ context.define_singleton_method(:confirm_live_gh_command!) { |*command| confirmations << command }
86
+
87
+ assert PumaRelease::GitHubClient.new(context).delete_tag_ref("v7.3.0-proposal")
88
+ assert_equal [["gh", "api", "-X", "DELETE", "repos/nateberkopec/puma/git/refs/tags/v7.3.0-proposal"]], confirmations
89
+ end
90
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ class GithubCommandTest < Minitest::Test
6
+ def test_checks_git_and_gh_and_requires_a_clean_main_checkout
7
+ calls = []
8
+ context = Object.new
9
+ context.define_singleton_method(:check_dependencies!) { |*commands| calls << commands }
10
+ context.define_singleton_method(:announce_live_mode!) {}
11
+ context.define_singleton_method(:ensure_release_writes_allowed!) {}
12
+
13
+ git_repo = Object.new
14
+ git_repo.define_singleton_method(:ensure_clean_base!) { calls << :clean_main }
15
+
16
+ repo_files = Object.new
17
+ repo_files.define_singleton_method(:current_version) { raise PumaRelease::Error, "stop" }
18
+
19
+ command = PumaRelease::Commands::Github.allocate
20
+ command.instance_variable_set(:@context, context)
21
+ command.instance_variable_set(:@git_repo, git_repo)
22
+ command.instance_variable_set(:@repo_files, repo_files)
23
+ command.instance_variable_set(:@github, Object.new)
24
+
25
+ error = assert_raises(PumaRelease::Error) { command.call }
26
+
27
+ assert_equal "stop", error.message
28
+ assert_includes calls, ["git", "gh"]
29
+ assert_includes calls, :clean_main
30
+ end
31
+
32
+ def test_ensures_the_signed_tag_is_pushed_before_creating_a_release
33
+ Dir.mktmpdir do |dir|
34
+ repo_dir = Pathname(dir)
35
+ context = OpenStruct.new(
36
+ repo_dir:,
37
+ history_file: repo_dir.join("History.md"),
38
+ events: Object.new,
39
+ ui: Object.new
40
+ )
41
+ context.history_file.write("## 7.2.0 / 2026-01-20\n\n* Bugfixes\n * One fix ([#1])\n")
42
+ context.define_singleton_method(:check_dependencies!) { |_git, _gh| }
43
+ context.define_singleton_method(:announce_live_mode!) {}
44
+ context.define_singleton_method(:ensure_release_writes_allowed!) {}
45
+ context.events.define_singleton_method(:publish) { |_name, _payload| }
46
+ context.ui.define_singleton_method(:info) { |_message| }
47
+
48
+ git_repo = Object.new
49
+ git_repo.define_singleton_method(:ensure_clean_base!) {}
50
+ git_repo.define_singleton_method(:release_tag) { |_version| "v7.2.0" }
51
+ git_repo.define_singleton_method(:proposal_tag) { |_version| "v7.2.0-proposal" }
52
+ git_repo.define_singleton_method(:ensure_release_tag_pushed!) { |_tag| raise PumaRelease::Error, "stop" }
53
+
54
+ repo_files = Object.new
55
+ repo_files.define_singleton_method(:current_version) { "7.2.0" }
56
+ repo_files.define_singleton_method(:extract_history_section) { |_version| "* Bugfixes\n * One fix ([#1])" }
57
+ repo_files.define_singleton_method(:release_name) { |_version| "v7.2.0" }
58
+
59
+ github = Object.new
60
+ github.define_singleton_method(:create_release) { |_tag, _body, title:, draft:| flunk "create_release should not be called before the tag is ensured" }
61
+ github.define_singleton_method(:release) { |_tag| nil }
62
+
63
+ command = PumaRelease::Commands::Github.allocate
64
+ command.instance_variable_set(:@context, context)
65
+ command.instance_variable_set(:@git_repo, git_repo)
66
+ command.instance_variable_set(:@repo_files, repo_files)
67
+ command.instance_variable_set(:@github, github)
68
+
69
+ repo_dir.join("pkg").mkpath
70
+ repo_dir.join("pkg/puma-7.2.0.gem").write("")
71
+ repo_dir.join("pkg/puma-7.2.0-java.gem").write("")
72
+
73
+ error = assert_raises(PumaRelease::Error) { command.call }
74
+
75
+ assert_equal "stop", error.message
76
+ end
77
+ end
78
+
79
+ def test_promotes_the_proposal_release_to_the_final_tag
80
+ Dir.mktmpdir do |dir|
81
+ repo_dir = Pathname(dir)
82
+ context = OpenStruct.new(
83
+ repo_dir:,
84
+ history_file: repo_dir.join("History.md"),
85
+ events: Object.new,
86
+ ui: Object.new
87
+ )
88
+ context.history_file.write("## 7.2.0 / 2026-01-20\n\n* Bugfixes\n * One fix ([#1])\n")
89
+ published = []
90
+ infos = []
91
+ context.define_singleton_method(:check_dependencies!) { |_git, _gh| }
92
+ context.define_singleton_method(:announce_live_mode!) {}
93
+ context.define_singleton_method(:ensure_release_writes_allowed!) {}
94
+ context.events.define_singleton_method(:publish) { |name, payload| published << [name, payload] }
95
+ context.ui.define_singleton_method(:info) { |message| infos << message }
96
+
97
+ git_repo = Object.new
98
+ git_repo.define_singleton_method(:ensure_clean_base!) {}
99
+ git_repo.define_singleton_method(:release_tag) { |_version| "v7.2.0" }
100
+ git_repo.define_singleton_method(:proposal_tag) { |_version| "v7.2.0-proposal" }
101
+ git_repo.define_singleton_method(:ensure_release_tag_pushed!) { |_tag| }
102
+ git_repo.define_singleton_method(:local_tag_sha) { |_tag| "abc123" }
103
+ git_repo.define_singleton_method(:remote_tag_sha) { |_tag| "proposal123" }
104
+
105
+ repo_files = Object.new
106
+ repo_files.define_singleton_method(:current_version) { "7.2.0" }
107
+ repo_files.define_singleton_method(:extract_history_section) { |_version| "* Bugfixes\n * One fix ([#1])" }
108
+ repo_files.define_singleton_method(:release_name) { |_version| "v7.2.0" }
109
+
110
+ calls = []
111
+ github = Object.new
112
+ github.define_singleton_method(:release) do |tag|
113
+ case tag
114
+ when "v7.2.0" then nil
115
+ when "v7.2.0-proposal" then {"isDraft" => true, "targetCommitish" => "release-v7.2.0", "name" => "v7.2.0", "body" => "* Bugfixes\n * One fix ([#1])", "url" => "https://example.test/release"}
116
+ end
117
+ end
118
+ github.define_singleton_method(:retag_release) do |old_tag, new_tag, target:|
119
+ calls << [:retag_release, old_tag, new_tag, target]
120
+ {"isDraft" => true, "targetCommitish" => target, "name" => "v7.2.0", "body" => "* Bugfixes\n * One fix ([#1])", "url" => "https://example.test/release"}
121
+ end
122
+ github.define_singleton_method(:edit_release_target) { |_tag, _target| flunk "edit_release_target should not be called when retag_release already set the final target" }
123
+ github.define_singleton_method(:edit_release_title) { |_tag, _title| flunk "edit_release_title should not be called when the release title already matches" }
124
+ github.define_singleton_method(:edit_release_notes) { |_tag, _body| flunk "edit_release_notes should not be called when the release notes already match" }
125
+ github.define_singleton_method(:upload_release_assets) { |tag, *paths| calls << [:upload_release_assets, tag, paths.map { |path| File.basename(path) }] }
126
+ github.define_singleton_method(:publish_release) do |tag|
127
+ calls << [:publish_release, tag]
128
+ {"isDraft" => false, "url" => "https://example.test/release"}
129
+ end
130
+ github.define_singleton_method(:delete_release) { |_tag, allow_failure:| calls << [:delete_release, allow_failure] }
131
+ github.define_singleton_method(:delete_tag_ref) { |tag, allow_failure:| calls << [:delete_tag_ref, tag, allow_failure] }
132
+ github.define_singleton_method(:create_release) { |_tag, _body, title:, draft:| flunk "create_release should not be called when a proposal release already exists" }
133
+
134
+ command = PumaRelease::Commands::Github.allocate
135
+ command.instance_variable_set(:@context, context)
136
+ command.instance_variable_set(:@git_repo, git_repo)
137
+ command.instance_variable_set(:@repo_files, repo_files)
138
+ command.instance_variable_set(:@github, github)
139
+
140
+ repo_dir.join("pkg").mkpath
141
+ repo_dir.join("pkg/puma-7.2.0.gem").write("")
142
+ repo_dir.join("pkg/puma-7.2.0-java.gem").write("")
143
+
144
+ assert_equal :complete, command.call
145
+ assert_includes infos, "Promoting draft release from v7.2.0-proposal to v7.2.0..."
146
+ assert_includes calls, [:retag_release, "v7.2.0-proposal", "v7.2.0", "abc123"]
147
+ assert_includes calls, [:upload_release_assets, "v7.2.0", ["puma-7.2.0.gem", "puma-7.2.0-java.gem"]]
148
+ assert_includes calls, [:publish_release, "v7.2.0"]
149
+ assert_includes calls, [:delete_tag_ref, "v7.2.0-proposal", true]
150
+ assert_equal [[:release_published, {tag: "v7.2.0", url: "https://example.test/release"}]], published
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ class OptionsTest < Minitest::Test
6
+ def test_parse_sets_live_flag
7
+ options = PumaRelease::Options.parse(["--live"])
8
+
9
+ assert_equal true, options.fetch(:live)
10
+ end
11
+
12
+ def test_parse_sets_skip_ci_check_flag
13
+ options = PumaRelease::Options.parse(["--skip-ci-check"])
14
+
15
+ assert_equal true, options.fetch(:skip_ci_check)
16
+ end
17
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ class PrepareTest < Minitest::Test
6
+ class FakeUI
7
+ attr_reader :infos, :warnings
8
+
9
+ def initialize
10
+ @infos = []
11
+ @warnings = []
12
+ end
13
+
14
+ def info(message)
15
+ infos << message
16
+ end
17
+
18
+ def warn(message)
19
+ warnings << message
20
+ end
21
+ end
22
+
23
+ def test_ensure_green_ci_skips_when_flag_is_set
24
+ ui = FakeUI.new
25
+ context = OpenStruct.new(skip_ci_check?: true, ui:)
26
+ git_repo = OpenStruct.new(head_sha: "abc123")
27
+ prepare = PumaRelease::Commands::Prepare.allocate
28
+ prepare.instance_variable_set(:@context, context)
29
+ prepare.instance_variable_set(:@git_repo, git_repo)
30
+
31
+ checker = Object.new
32
+ def checker.ensure_green!(_sha)
33
+ flunk "CiChecker should not be called when CI is skipped"
34
+ end
35
+ prepare.define_singleton_method(:ci_checker) { checker }
36
+
37
+ prepare.send(:ensure_green_ci!)
38
+
39
+ assert_equal ["Skipping CI check because --skip-ci-check was set."], ui.warnings
40
+ assert_empty ui.infos
41
+ end
42
+
43
+ def test_ensure_green_ci_checks_head_when_flag_is_not_set
44
+ ui = FakeUI.new
45
+ context = OpenStruct.new(skip_ci_check?: false, ui:)
46
+ git_repo = OpenStruct.new(head_sha: "abc123")
47
+ prepare = PumaRelease::Commands::Prepare.allocate
48
+ prepare.instance_variable_set(:@context, context)
49
+ prepare.instance_variable_set(:@git_repo, git_repo)
50
+
51
+ checker = Object.new
52
+ calls = []
53
+ checker.define_singleton_method(:ensure_green!) { |sha| calls << sha }
54
+ prepare.define_singleton_method(:ci_checker) { checker }
55
+
56
+ prepare.send(:ensure_green_ci!)
57
+
58
+ assert_equal ["Checking CI status for HEAD..."], ui.infos
59
+ assert_equal ["abc123"], calls
60
+ assert_empty ui.warnings
61
+ end
62
+
63
+ def test_pr_comment_starts_with_llm_attribution
64
+ context = Object.new
65
+ context.define_singleton_method(:comment_attribution) do |model_name|
66
+ "This comment was written by #{model_name} working on behalf of [puma-release](https://github.com/nateberkopec/puma-release)."
67
+ end
68
+ context.define_singleton_method(:comment_author_model_name) { "fallback-model" }
69
+ prepare = PumaRelease::Commands::Prepare.allocate
70
+ prepare.instance_variable_set(:@context, context)
71
+
72
+ comment = prepare.send(
73
+ :pr_comment,
74
+ {
75
+ "model_name" => "openai-codex/gpt-5.4",
76
+ "bump_type" => "minor",
77
+ "reasoning_markdown" => "Because of [this commit](https://github.com/puma/puma/commit/abc)."
78
+ },
79
+ nil
80
+ )
81
+
82
+ assert_match(%r{\AThis comment was written by openai-codex/gpt-5\.4 working on behalf of \[puma-release\]\(https://github.com/nateberkopec/puma-release\)\.}, comment)
83
+ end
84
+
85
+ def test_ensure_draft_release_uses_the_proposal_tag
86
+ git_repo = Object.new
87
+ git_repo.define_singleton_method(:proposal_tag) { |_version| "v7.3.0-proposal" }
88
+
89
+ repo_files = Object.new
90
+ repo_files.define_singleton_method(:release_name) { |_version| "v7.3.0 - Example" }
91
+ repo_files.define_singleton_method(:extract_history_section) { |_version| "* Features\n * Example ([#1])" }
92
+
93
+ calls = []
94
+ github = Object.new
95
+ github.define_singleton_method(:release) { |_tag| nil }
96
+ github.define_singleton_method(:create_release) do |tag, body, title:, draft:, target:|
97
+ calls << [:create_release, tag, body, title, draft, target]
98
+ {"name" => title, "body" => body, "targetCommitish" => target}
99
+ end
100
+ github.define_singleton_method(:edit_release_target) { |_tag, _target| flunk "edit_release_target should not be called when the draft release target already matches" }
101
+ github.define_singleton_method(:edit_release_title) { |_tag, _title| flunk "edit_release_title should not be called when the title already matches" }
102
+ github.define_singleton_method(:edit_release_notes) { |_tag, _body| flunk "edit_release_notes should not be called when the notes already match" }
103
+
104
+ prepare = PumaRelease::Commands::Prepare.allocate
105
+ prepare.instance_variable_set(:@context, OpenStruct.new(history_file: Pathname("History.md")))
106
+ prepare.instance_variable_set(:@git_repo, git_repo)
107
+ prepare.instance_variable_set(:@repo_files, repo_files)
108
+ prepare.instance_variable_set(:@github, github)
109
+
110
+ prepare.send(:ensure_draft_release, "7.3.0", "release-v7.3.0")
111
+
112
+ assert_equal [[:create_release, "v7.3.0-proposal", "* Features\n * Example ([#1])", "v7.3.0 - Example", true, "release-v7.3.0"]], calls
113
+ end
114
+
115
+ def test_show_version_recommendation_prints_reasoning_and_breaking_changes
116
+ ui = FakeUI.new
117
+ context = OpenStruct.new(ui:)
118
+ prepare = PumaRelease::Commands::Prepare.allocate
119
+ prepare.instance_variable_set(:@context, context)
120
+
121
+ output = capture_io do
122
+ prepare.send(
123
+ :show_version_recommendation,
124
+ {
125
+ "reasoning_markdown" => "Major because of [this commit](https://github.com/puma/puma/commit/abc123).",
126
+ "breaking_changes" => ["Dropped support for an older Rack integration"]
127
+ }
128
+ )
129
+ end.first
130
+
131
+ assert_equal ["Version bump recommendation:"], ui.infos
132
+ assert_equal ["Potential breaking changes:"], ui.warnings
133
+ assert_includes output, "Major because of [this commit](https://github.com/puma/puma/commit/abc123)."
134
+ assert_includes output, "- Dropped support for an older Rack integration"
135
+ end
136
+ end