bake-gem-github 0.2.0 → 0.3.1

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.
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "rubygems/package"
7
+
8
+ module Bake
9
+ module Gem
10
+ module GitHub
11
+ # Stores the original release files together so a single completed upload can recover them.
12
+ module Backup
13
+ # Write the release files to a tar archive.
14
+ # @parameter path [String] The destination archive path.
15
+ # @parameter files [Array(String)] Original release files, stored under their basenames.
16
+ # @returns [Nil] After writing and closing the archive.
17
+ def self.write(path, files)
18
+ File.open(path, "wb") do |output|
19
+ ::Gem::Package::TarWriter.new(output) do |archive|
20
+ files.each do |file|
21
+ archive.add_file(File.basename(file), 0644){|entry| entry.write(File.binread(file))}
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ # Read only the expected regular files; reject missing, duplicate, or unexpected entries before extraction.
28
+ # @parameter path [String] The archive to inspect without extracting filesystem paths.
29
+ # @parameter names [Array(String)] Exactly the permitted basenames for the gem, receipt, and two attestation files.
30
+ # @returns [Hash(String, String)] Binary file contents keyed by basename.
31
+ # @raises [RuntimeError] If entries are missing, duplicated, unexpected, or not regular files.
32
+ def self.read(path, names)
33
+ files = {}
34
+
35
+ File.open(path, "rb") do |input|
36
+ ::Gem::Package::TarReader.new(input) do |archive|
37
+ archive.each do |entry|
38
+ name = entry.full_name
39
+ raise "Unexpected release backup entry: #{name}" unless entry.file? && names.include?(name) && !files.key?(name)
40
+ files[name] = entry.read
41
+ end
42
+ end
43
+ end
44
+ raise "Release backup is incomplete." unless files.keys.sort == names.sort
45
+
46
+ return files
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -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,290 @@ 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.
73
- def inspect_release(number)
113
+ # @parameter number [String | Integer] The merged PR number.
114
+ # @parameter commit [String | Nil] The expected merge commit, when resolving a push event.
115
+ # @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`.
116
+ # @raises [RuntimeError] If the merged source does not match the independently generated release.
117
+ def inspect_release(number, commit: nil)
74
118
  pr = merged(number)
119
+ raise "PR merge commit does not match the pushed commit." if commit && pr.fetch("merge_commit_sha") != commit
120
+
75
121
  commit = pr.fetch("merge_commit_sha")
76
122
  metadata = @release.validate(base: "#{commit}^1", candidate: commit, optional: true)
77
123
  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"))
124
+ return metadata.merge(
125
+ repository: @repository,
126
+ pull_request: pr.fetch("number"),
127
+ merged_by: pr.dig("merged_by", "login"),
128
+ pull_request_url: pr.fetch("html_url"),
129
+ )
130
+ end
131
+ end
132
+
133
+ # Resolve a pushed release commit to the PR which merged it into the configured branch.
134
+ # @parameter commit [String] The full commit SHA from the push event.
135
+ # @returns [Hash | Nil] Release metadata from {inspect_release}, or nil for an ordinary change.
136
+ # @raises [RuntimeError] If the commit is invalid or a release has no unique matching merged PR.
137
+ # @raises [Bake::Gem::CommandExecutionError] If Git or GitHub cannot verify the release.
138
+ def inspect_commit(commit)
139
+ raise "Expected a full pushed commit SHA." unless commit.match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/)
140
+ responses = readlines(
141
+ "gh", "api", "repos/#{@repository}/commits/#{commit}/pulls?per_page=100",
142
+ "--paginate", "--slurp", chdir: @root,
143
+ )
144
+ pulls = JSON.parse(responses.join).flatten(1).select do |pr|
145
+ pr["merged_at"] && pr["merge_commit_sha"] == commit &&
146
+ pr.dig("base", "ref") == @config.fetch("branch") && pr.dig("base", "repo", "full_name") == @repository
79
147
  end
148
+
149
+ if pulls.empty?
150
+ return nil unless @release.validate(base: "#{commit}^1", candidate: commit, optional: true)
151
+ raise "Release commit has no matching merged PR."
152
+ end
153
+ raise "Multiple merged PRs match the pushed commit." if pulls.size > 1
154
+
155
+ return inspect_release(pulls.first.fetch("number"), commit: commit)
80
156
  end
81
157
 
82
158
  # Return a read-only comparison of managed settings and current repository settings.
159
+ # @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
160
  def doctor
84
161
  {
85
162
  desired_rules: Setup.rules(@config),
86
163
  existing_rules: api("rulesets?per_page=100"),
87
164
  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")}
165
+ environment_changes: environment_changes,
166
+ trusted_publisher: {
167
+ repository_owner: @repository.split("/").first,
168
+ repository_name: @repository.split("/").last,
169
+ workflow_filename: "release-publish.yaml",
170
+ environment: @config.fetch("environment"),
171
+ }
89
172
  }
90
173
  end
91
174
 
92
- # Apply only the named rulesets generated by setup. Invoke after reviewing doctor output.
175
+ # Apply the named rulesets and configured reviewers for an existing environment. Invoke after reviewing doctor output.
176
+ # Preserves the environment's wait timer, self-review prevention, administrator bypass, and branch restrictions.
177
+ # @returns [Hash] The desired ruleset payloads after successful application.
178
+ # @raises [RuntimeError] If more than one existing ruleset has a managed name.
179
+ # @raises [Bake::Gem::CommandExecutionError] If an API operation fails; earlier updates may already have completed.
93
180
  def apply
181
+ changes = environment_changes
94
182
  existing = api("rulesets?per_page=100")
95
- Setup.rules(@config).each_value do |rule|
183
+ rules = Setup.rules(@config)
184
+ rules.each_value do |rule|
96
185
  matches = existing.select{|current| current.fetch("name") == rule.fetch(:name)}
97
186
  raise "Multiple rulesets match #{rule[:name]}." if matches.size > 1
98
187
  current = matches.first
99
188
  path = "repos/#{@repository}/rulesets"
100
189
  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
190
+
191
+ write_api(path, rule, method: current ? "PUT" : "POST")
192
+ end
193
+
194
+ if changes && changes.fetch(:current) != changes.fetch(:desired)
195
+ write_api("repos/#{@repository}/#{environment_path}", changes.fetch(:desired), method: "PUT")
196
+ end
197
+
198
+ return rules
199
+ end
200
+
201
+ private
202
+
203
+ def environment_path
204
+ "environments/#{URI.encode_www_form_component(@config.fetch('environment')).gsub('+', '%20')}"
205
+ end
206
+
207
+ # Resolve reviewers before making any changes, and preserve unrelated environment settings.
208
+ def environment_changes
209
+ return nil unless @config.key?("reviewers")
210
+ Setup.validate_reviewers(@config["reviewers"])
211
+
212
+ environment = api(environment_path)
213
+ protections = environment.fetch("protection_rules").to_h{|rule| [rule.fetch("type"), rule]}
214
+ reviews = protections.fetch("required_reviewers", {})
215
+ current = {
216
+ wait_timer: protections.fetch("wait_timer", {}).fetch("wait_timer", 0),
217
+ prevent_self_review: reviews.fetch("prevent_self_review", false),
218
+ can_admins_bypass: environment.fetch("can_admins_bypass"),
219
+ deployment_branch_policy: environment.fetch("deployment_branch_policy"),
220
+ reviewers: reviews.fetch("reviewers", []).map{|entry| {type: entry.fetch("type"), id: entry.fetch("reviewer").fetch("id")}},
221
+ }
222
+ reviewers = @config.fetch("reviewers").map{|name| resolve_reviewer(name)}
223
+
224
+ return {name: @config.fetch("environment"), current: current, desired: current.merge(reviewers: reviewers)}
225
+ end
226
+
227
+ def resolve_reviewer(name)
228
+ if name.include?("/")
229
+ organization, team = name.split("/", 2)
230
+ raise "Reviewer team must belong to #{@repository.split('/').first}." unless organization.casecmp?(@repository.split("/").first)
231
+ path = "orgs/#{organization}/teams/#{team}"
232
+ type = "Team"
233
+ else
234
+ path = "users/#{name}"
235
+ type = "User"
236
+ end
237
+ response = JSON.parse(readlines("gh", "api", path, chdir: @root).join)
238
+
239
+ return {type: type, id: response.fetch("id")}
240
+ end
241
+
242
+ def write_api(path, payload, method:)
243
+ return Tempfile.create("release-settings") do |file|
244
+ file.write(JSON.generate(payload))
245
+ file.flush
246
+ system("gh", "api", path, "--method", method, "--input", file.path, chdir: @root)
106
247
  end
107
248
  end
249
+
250
+ # Find the repository's sole release PR, excluding forks.
251
+ def find_release_pull_request(branch)
252
+ response = readlines(
253
+ "gh", "pr", "list", "--repo", @repository, "--base", branch,
254
+ "--state", "open", "--json", "headRefName,url,isCrossRepository",
255
+ "--limit", "1000", chdir: @root,
256
+ )
257
+ pull_requests = JSON.parse(response.join).select do |pull_request|
258
+ !pull_request["isCrossRepository"] && pull_request.fetch("headRefName").start_with?("releases/v")
259
+ end
260
+ raise "Multiple release PRs are open; select one before preparing another release." if pull_requests.size > 1
261
+
262
+ return pull_requests.first
263
+ end
264
+
265
+ # Fetch the remote branch and return the commit used for the push lease.
266
+ def fetch_release_branch(reference)
267
+ remote = readlines("git", "ls-remote", "--heads", "origin", reference, chdir: @root)
268
+ return nil if remote.empty?
269
+
270
+ system("git", "fetch", "origin", reference, chdir: @root)
271
+
272
+ return @release.resolve("FETCH_HEAD")
273
+ end
274
+
275
+ # Locate preparation which stopped before pushing its branch.
276
+ def local_release_commit(branch)
277
+ return nil if readlines("git", "branch", "--list", branch, chdir: @root).empty?
278
+
279
+ return @release.resolve("refs/heads/#{branch}")
280
+ end
281
+
282
+ # Preserve the previous release before regenerating it from the current base.
283
+ def refresh_release(candidate, base:, bump:, version:)
284
+ backup_ref = "refs/heads/release-backups/v#{version}/#{candidate}"
285
+ push("#{candidate}:#{backup_ref}")
286
+
287
+ return @release.worktree(base) do |path|
288
+ @release.bake(path, "gem:release:version:#{bump}")
289
+ readlines("git", "rev-parse", "HEAD", chdir: path).join.strip
290
+ end
291
+ end
292
+
293
+ # Open a PR for the already validated and pushed release branch.
294
+ def create_release_pull_request(name, version, branch:, release_branch:, base:)
295
+ body = <<~BODY
296
+ Release #{name} #{version}.
297
+
298
+ Prepared from #{base}. The complete release tree is regenerated during validation. \
299
+ Merging publishes the resulting commit through release-publish.yaml after native \
300
+ reviews and required CI (or explicit administrator bypass).
301
+ BODY
302
+
303
+ return Tempfile.create("release-pr") do |file|
304
+ file.write(body)
305
+ file.flush
306
+
307
+ readlines(
308
+ "gh", "pr", "create", "--repo", @repository, "--base", branch,
309
+ "--head", release_branch, "--title", "Release v#{version}",
310
+ "--body-file", file.path, chdir: @root,
311
+ ).join.strip
312
+ end
313
+ end
314
+
315
+ def push(*arguments)
316
+ system("git", "-c", "credential.helper=", "-c", "credential.helper=!gh auth git-credential", "push", "origin", *arguments, chdir: @root)
317
+ end
108
318
  end
109
319
  end
110
320
  end