bake-gem-github 0.1.0 → 0.3.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.
@@ -6,6 +6,7 @@
6
6
  require "bake/gem/release"
7
7
  require "yaml"
8
8
  require "tempfile"
9
+ require "uri"
9
10
  require_relative "setup"
10
11
 
11
12
  module Bake
@@ -16,6 +17,8 @@ module Bake
16
17
  include Shell
17
18
 
18
19
  # Load the reviewed repository release policy.
20
+ # @parameter root [String] The repository root containing `config/release.yaml`.
21
+ # @raises [RuntimeError] If the configuration schema is unsupported.
19
22
  def initialize(root)
20
23
  @root = File.expand_path(root)
21
24
  @config = YAML.safe_load_file(File.join(@root, "config/release.yaml"))
@@ -28,83 +31,263 @@ module Bake
28
31
  attr_reader :config
29
32
 
30
33
  # Execute a GitHub API read. Failures never imply that a resource is absent.
34
+ # @parameter path [String] An API path relative to this repository.
35
+ # @returns [Hash | Array] The decoded GitHub response, retaining string keys.
36
+ # @raises [Bake::Gem::CommandExecutionError] If the GitHub request fails.
31
37
  def api(path)
32
38
  JSON.parse(readlines("gh", "api", "repos/#{@repository}/#{path}", chdir: @root).join)
33
39
  end
34
40
 
35
41
  # Prepare a release through core Bake tasks, then push and create its pull request.
36
- def prepare(context, bump)
42
+ #
43
+ # The process must already be in the repository root because {Bake::Gem::Helper} evaluates its gemspec.
44
+ # Refresh preserves the previous release commit before replacing the remote branch with an explicit push lease.
45
+ #
46
+ # @parameter context [Bake::Context] The consumer context used to invoke core release tasks.
47
+ # @parameter bump [String] The stable version increment: `patch`, `minor`, or `major`.
48
+ # @parameter refresh [Boolean] Whether to regenerate an existing release from the current base.
49
+ # @returns [String] The new or existing release PR URL.
50
+ # @raises [RuntimeError] If the checkout, existing PR, or generated release content is unsuitable.
51
+ # @raises [Bake::Gem::CommandExecutionError] If a Git or GitHub operation fails, including a conflicting push.
52
+ def prepare(context, bump, refresh: false)
37
53
  Release::BUMPS.fetch(bump)
38
54
  helper = Helper.new(@root)
39
55
  helper.guard_clean
56
+
40
57
  branch = @config.fetch("branch")
41
58
  raise "Prepare releases from #{branch}." unless helper.current_branch == branch
42
59
  system("git", "fetch", "origin", branch, "--tags", chdir: @root)
43
60
  raise "Local branch differs from origin/#{branch}." unless @release.resolve("HEAD") == @release.resolve("origin/#{branch}")
44
- pulls = JSON.parse(readlines("gh", "pr", "list", "--repo", @repository, "--base", branch, "--state", "open", "--json", "headRefName,url", "--limit", "1000", chdir: @root).join)
45
- if existing = pulls.find{|pr| pr.fetch("headRefName").start_with?("releases/v")}
46
- return existing.fetch("url")
61
+
62
+ pull_request = find_release_pull_request(branch)
63
+ version = Version.new(helper.gemspec.version.segments, nil).increment(Release::BUMPS.fetch(bump)).join
64
+ release_branch = "releases/v#{version}"
65
+ if pull_request && pull_request.fetch("headRefName") != release_branch
66
+ raise "Existing release PR uses #{pull_request.fetch('headRefName')}; use its bump type or close it first."
47
67
  end
68
+
48
69
  base = @release.resolve("HEAD")
49
- result = context.lookup("gem:release:branch:#{bump}").call
50
- @release.validate(base: base)
51
- system("git", "-c", "credential.helper=", "-c", "credential.helper=!gh auth git-credential", "push", "--set-upstream", "origin", result.fetch(:branch), chdir: @root)
52
- body = "Release #{helper.gemspec.name} #{result.fetch(:version)}.\n\nPrepared from #{base}. The complete release tree is regenerated during validation. Merging publishes the resulting commit through release-publish.yaml after native reviews and required CI (or explicit administrator bypass).\n"
53
- Tempfile.create("release-pr") do |file|
54
- file.write(body)
55
- file.flush
56
- readlines("gh", "pr", "create", "--repo", @repository, "--base", branch, "--head", result.fetch(:branch), "--title", "Release v#{result.fetch(:version)}", "--body-file", file.path, chdir: @root).join.strip
70
+ release_ref = "refs/heads/#{release_branch}"
71
+ remote_commit = fetch_release_branch(release_ref)
72
+ candidate = remote_commit || local_release_commit(release_branch)
73
+
74
+ if candidate && refresh
75
+ candidate = refresh_release(candidate, base: base, bump: bump, version: version)
76
+ elsif !candidate
77
+ context.lookup("gem:release:branch:#{bump}").call
78
+ candidate = @release.resolve("HEAD")
57
79
  end
80
+
81
+ metadata = @release.validate(base: base, candidate: candidate)
82
+ raise "Release branch does not contain the requested version #{version}." unless metadata.fetch(:version) == version
83
+
84
+ push("--force-with-lease=#{release_ref}:#{remote_commit}", "#{candidate}:#{release_ref}")
85
+ return pull_request.fetch("url") if pull_request
86
+
87
+ return create_release_pull_request(
88
+ helper.gemspec.name, version,
89
+ branch: branch, release_branch: release_branch, base: base,
90
+ )
58
91
  end
59
92
 
60
93
  # Resolve a merged PR through GitHub, and require its actual merge commit in default-branch history.
94
+ # @parameter number [String | Integer] The positive PR number.
95
+ # @returns [Hash] GitHub PR data with string keys, including `number` and `merge_commit_sha`.
96
+ # @raises [RuntimeError] If the PR is invalid, unmerged, or targets another repository or branch.
97
+ # @raises [Bake::Gem::CommandExecutionError] If its commit is outside the default branch history or a command fails.
61
98
  def merged(number)
62
99
  raise "Expected a PR number." unless number.to_s.match?(/\A[1-9]\d*\z/)
63
100
  pr = api("pulls/#{number}")
64
101
  raise "PR must be merged into the configured branch." unless pr["merged"] && pr.dig("base", "ref") == @config.fetch("branch") && pr.dig("base", "repo", "full_name") == @repository
102
+
65
103
  commit = pr.fetch("merge_commit_sha")
66
104
  raise "Invalid merged commit." unless commit.match?(/\A[0-9a-f]{40,64}\z/)
105
+
67
106
  system("git", "fetch", "origin", @config.fetch("branch"), "--tags", chdir: @root)
68
107
  system("git", "merge-base", "--is-ancestor", commit, "origin/#{@config.fetch('branch')}", chdir: @root)
69
- pr
108
+
109
+ return pr
70
110
  end
71
111
 
72
112
  # Resolve release identity; ordinary merged PRs do not publish.
113
+ # @parameter number [String | Integer] The merged PR number.
114
+ # @returns [Hash | Nil] Release metadata with symbol keys, or nil for an ordinary PR. Includes `name`, `version`, `commit`, `base`, `bump`, `repository`, `pull_request`, `merged_by`, and `pull_request_url`.
115
+ # @raises [RuntimeError] If the merged source does not match the independently generated release.
73
116
  def inspect_release(number)
74
117
  pr = merged(number)
118
+
75
119
  commit = pr.fetch("merge_commit_sha")
76
120
  metadata = @release.validate(base: "#{commit}^1", candidate: commit, optional: true)
77
121
  if metadata
78
- metadata.merge(repository: @repository, pull_request: pr.fetch("number"), merged_by: pr.dig("merged_by", "login"), pull_request_url: pr.fetch("html_url"))
122
+ return metadata.merge(
123
+ repository: @repository,
124
+ pull_request: pr.fetch("number"),
125
+ merged_by: pr.dig("merged_by", "login"),
126
+ pull_request_url: pr.fetch("html_url"),
127
+ )
79
128
  end
80
129
  end
81
130
 
82
131
  # Return a read-only comparison of managed settings and current repository settings.
132
+ # @returns [Hash] Desired rules, existing rules, environments, optional environment changes, and expected Trusted Publisher settings. This does not verify RubyGems ownership or publisher configuration.
83
133
  def doctor
84
134
  {
85
135
  desired_rules: Setup.rules(@config),
86
136
  existing_rules: api("rulesets?per_page=100"),
87
137
  environments: api("environments"),
88
- trusted_publisher: {repository_owner: @repository.split("/").first, repository_name: @repository.split("/").last, workflow_filename: "release-publish.yaml", environment: @config.fetch("environment")}
138
+ environment_changes: environment_changes,
139
+ trusted_publisher: {
140
+ repository_owner: @repository.split("/").first,
141
+ repository_name: @repository.split("/").last,
142
+ workflow_filename: "release-publish.yaml",
143
+ environment: @config.fetch("environment"),
144
+ }
89
145
  }
90
146
  end
91
147
 
92
- # Apply only the named rulesets generated by setup. Invoke after reviewing doctor output.
148
+ # Apply the named rulesets and configured reviewers for an existing environment. Invoke after reviewing doctor output.
149
+ # Preserves the environment's wait timer, self-review prevention, administrator bypass, and branch restrictions.
150
+ # @returns [Hash] The desired ruleset payloads after successful application.
151
+ # @raises [RuntimeError] If more than one existing ruleset has a managed name.
152
+ # @raises [Bake::Gem::CommandExecutionError] If an API operation fails; earlier updates may already have completed.
93
153
  def apply
154
+ changes = environment_changes
94
155
  existing = api("rulesets?per_page=100")
95
- Setup.rules(@config).each_value do |rule|
156
+ rules = Setup.rules(@config)
157
+ rules.each_value do |rule|
96
158
  matches = existing.select{|current| current.fetch("name") == rule.fetch(:name)}
97
159
  raise "Multiple rulesets match #{rule[:name]}." if matches.size > 1
98
160
  current = matches.first
99
161
  path = "repos/#{@repository}/rulesets"
100
162
  path += "/#{current.fetch('id')}" if current
101
- Tempfile.create("release-rule") do |file|
102
- file.write(JSON.generate(rule))
103
- file.flush
104
- system("gh", "api", path, "--method", current ? "PUT" : "POST", "--input", file.path, chdir: @root)
105
- end
163
+
164
+ write_api(path, rule, method: current ? "PUT" : "POST")
165
+ end
166
+
167
+ if changes && changes.fetch(:current) != changes.fetch(:desired)
168
+ write_api("repos/#{@repository}/#{environment_path}", changes.fetch(:desired), method: "PUT")
169
+ end
170
+
171
+ return rules
172
+ end
173
+
174
+ private
175
+
176
+ def environment_path
177
+ "environments/#{URI.encode_www_form_component(@config.fetch('environment')).gsub('+', '%20')}"
178
+ end
179
+
180
+ # Resolve reviewers before making any changes, and preserve unrelated environment settings.
181
+ def environment_changes
182
+ return nil unless @config.key?("reviewers")
183
+ Setup.validate_reviewers(@config["reviewers"])
184
+
185
+ environment = api(environment_path)
186
+ protections = environment.fetch("protection_rules").to_h{|rule| [rule.fetch("type"), rule]}
187
+ reviews = protections.fetch("required_reviewers", {})
188
+ current = {
189
+ wait_timer: protections.fetch("wait_timer", {}).fetch("wait_timer", 0),
190
+ prevent_self_review: reviews.fetch("prevent_self_review", false),
191
+ can_admins_bypass: environment.fetch("can_admins_bypass"),
192
+ deployment_branch_policy: environment.fetch("deployment_branch_policy"),
193
+ reviewers: reviews.fetch("reviewers", []).map{|entry| {type: entry.fetch("type"), id: entry.fetch("reviewer").fetch("id")}},
194
+ }
195
+ reviewers = @config.fetch("reviewers").map{|name| resolve_reviewer(name)}
196
+
197
+ return {name: @config.fetch("environment"), current: current, desired: current.merge(reviewers: reviewers)}
198
+ end
199
+
200
+ def resolve_reviewer(name)
201
+ if name.include?("/")
202
+ organization, team = name.split("/", 2)
203
+ raise "Reviewer team must belong to #{@repository.split('/').first}." unless organization.casecmp?(@repository.split("/").first)
204
+ path = "orgs/#{organization}/teams/#{team}"
205
+ type = "Team"
206
+ else
207
+ path = "users/#{name}"
208
+ type = "User"
209
+ end
210
+ response = JSON.parse(readlines("gh", "api", path, chdir: @root).join)
211
+
212
+ return {type: type, id: response.fetch("id")}
213
+ end
214
+
215
+ def write_api(path, payload, method:)
216
+ return Tempfile.create("release-settings") do |file|
217
+ file.write(JSON.generate(payload))
218
+ file.flush
219
+ system("gh", "api", path, "--method", method, "--input", file.path, chdir: @root)
220
+ end
221
+ end
222
+
223
+ # Find the repository's sole release PR, excluding forks.
224
+ def find_release_pull_request(branch)
225
+ response = readlines(
226
+ "gh", "pr", "list", "--repo", @repository, "--base", branch,
227
+ "--state", "open", "--json", "headRefName,url,isCrossRepository",
228
+ "--limit", "1000", chdir: @root,
229
+ )
230
+ pull_requests = JSON.parse(response.join).select do |pull_request|
231
+ !pull_request["isCrossRepository"] && pull_request.fetch("headRefName").start_with?("releases/v")
232
+ end
233
+ raise "Multiple release PRs are open; select one before preparing another release." if pull_requests.size > 1
234
+
235
+ return pull_requests.first
236
+ end
237
+
238
+ # Fetch the remote branch and return the commit used for the push lease.
239
+ def fetch_release_branch(reference)
240
+ remote = readlines("git", "ls-remote", "--heads", "origin", reference, chdir: @root)
241
+ return nil if remote.empty?
242
+
243
+ system("git", "fetch", "origin", reference, chdir: @root)
244
+
245
+ return @release.resolve("FETCH_HEAD")
246
+ end
247
+
248
+ # Locate preparation which stopped before pushing its branch.
249
+ def local_release_commit(branch)
250
+ return nil if readlines("git", "branch", "--list", branch, chdir: @root).empty?
251
+
252
+ return @release.resolve("refs/heads/#{branch}")
253
+ end
254
+
255
+ # Preserve the previous release before regenerating it from the current base.
256
+ def refresh_release(candidate, base:, bump:, version:)
257
+ backup_ref = "refs/heads/release-backups/v#{version}/#{candidate}"
258
+ push("#{candidate}:#{backup_ref}")
259
+
260
+ return @release.worktree(base) do |path|
261
+ @release.bake(path, "gem:release:version:#{bump}")
262
+ readlines("git", "rev-parse", "HEAD", chdir: path).join.strip
106
263
  end
107
264
  end
265
+
266
+ # Open a PR for the already validated and pushed release branch.
267
+ def create_release_pull_request(name, version, branch:, release_branch:, base:)
268
+ body = <<~BODY
269
+ Release #{name} #{version}.
270
+
271
+ Prepared from #{base}. The complete release tree is regenerated during validation. \
272
+ Merging publishes the resulting commit through release-publish.yaml after native \
273
+ reviews and required CI (or explicit administrator bypass).
274
+ BODY
275
+
276
+ return Tempfile.create("release-pr") do |file|
277
+ file.write(body)
278
+ file.flush
279
+
280
+ readlines(
281
+ "gh", "pr", "create", "--repo", @repository, "--base", branch,
282
+ "--head", release_branch, "--title", "Release v#{version}",
283
+ "--body-file", file.path, chdir: @root,
284
+ ).join.strip
285
+ end
286
+ end
287
+
288
+ def push(*arguments)
289
+ system("git", "-c", "credential.helper=", "-c", "credential.helper=!gh auth git-credential", "push", "origin", *arguments, chdir: @root)
290
+ end
108
291
  end
109
292
  end
110
293
  end