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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/bake/gem/github/release.rb +41 -15
- data/bake/gem/github/setup.rb +8 -3
- data/bake/gem/github.rb +23 -9
- data/context/getting-started.md +72 -69
- data/context/index.yaml +20 -4
- data/context/preparing-releases.md +48 -0
- data/context/recovering-releases.md +29 -0
- data/context/verifying-releases.md +43 -0
- data/lib/bake/gem/github/backup.rb +51 -0
- data/lib/bake/gem/github/project.rb +233 -23
- data/lib/bake/gem/github/publisher.rb +240 -120
- data/lib/bake/gem/github/registry.rb +104 -0
- data/lib/bake/gem/github/setup.rb +96 -13
- data/lib/bake/gem/github/version.rb +1 -1
- data/readme.md +89 -6
- data/releases.md +12 -0
- data/templates/release-prepare.yaml.erb +7 -2
- data/templates/release-publish.yaml.erb +14 -15
- data.tar.gz.sig +3 -1
- metadata +11 -4
- metadata.gz.sig +0 -0
- data/agents.md +0 -437
- data/templates/releasing.md +0 -17
|
@@ -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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
108
|
+
|
|
109
|
+
return pr
|
|
70
110
|
end
|
|
71
111
|
|
|
72
112
|
# Resolve release identity; ordinary merged PRs do not publish.
|
|
73
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|