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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/bake/gem/github/release.rb +32 -11
- data/bake/gem/github/setup.rb +8 -3
- data/bake/gem/github.rb +23 -9
- data/context/getting-started.md +70 -69
- data/context/index.yaml +20 -4
- data/context/preparing-releases.md +40 -0
- data/context/recovering-releases.md +27 -0
- data/context/verifying-releases.md +43 -0
- data/lib/bake/gem/github/backup.rb +51 -0
- data/lib/bake/gem/github/project.rb +205 -22
- 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 +84 -6
- data/releases.md +11 -0
- data/templates/release-prepare.yaml.erb +6 -1
- data.tar.gz.sig +0 -0
- metadata +11 -4
- metadata.gz.sig +0 -0
- data/agents.md +0 -437
- data/templates/releasing.md +0 -17
|
@@ -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
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|