Skip to content

Commit ac3e91c

Browse files
Configure publishing environment reviewers during setup. (#26)
1 parent a2ae70e commit ac3e91c

13 files changed

Lines changed: 292 additions & 25 deletions

File tree

‎bake/gem/github.rb‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
# @parameter repository [String] Canonical owner/repository; discovered through GitHub when omitted.
99
# @parameter branch [String] Default branch name; discovered through GitHub when omitted.
1010
# @parameter approvals [Integer] Number of approving reviews.
11+
# @parameter reviewers [Array(String)] Publishing environment reviewers: user logins or organization/team names. Omit to leave environment settings unmanaged.
1112
# @parameter signing [Boolean] Require certificate signing; when omitted, enable it if `release.cert` exists.
1213
# @parameter ruby [String] Ruby version for release workflows.
1314
# @returns [Array(String)] Generated paths relative to the repository root.
14-
def setup(checks:, repository: nil, branch: nil, approvals: 2, signing: nil, ruby: "3.4")
15+
def setup(checks:, repository: nil, branch: nil, approvals: 2, reviewers: nil, signing: nil, ruby: "3.4")
1516
require "bake/gem/github/setup"
1617
require "bake/gem/shell"
1718

@@ -27,17 +28,18 @@ def setup(checks:, repository: nil, branch: nil, approvals: 2, signing: nil, rub
2728
branch: branch || remote.fetch("defaultBranchRef").fetch("name"),
2829
checks: checks,
2930
approvals: approvals,
31+
reviewers: reviewers,
3032
ruby: ruby,
3133
}
3234
options[:signing] = signing unless signing.nil?
3335

34-
return Bake::Gem::GitHub::Setup.new(context.root).generate(**options)
36+
Bake::Gem::GitHub::Setup.new(context.root).generate(**options)
3537
end
3638

37-
# Show the desired rules, existing rules, environments, and RubyGems bootstrap values.
39+
# Show the desired rules and environment reviewers, existing settings, and RubyGems bootstrap values.
3840
# @returns [Hash] Desired and observed settings; RubyGems values describe the expected configuration.
3941
def doctor
4042
require "bake/gem/github/project"
4143

42-
return Bake::Gem::GitHub::Project.new(context.root).doctor
44+
Bake::Gem::GitHub::Project.new(context.root).doctor
4345
end

‎bake/gem/github/setup.rb‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,18 @@ def plan
99
context.lookup("gem:github:doctor").call
1010
end
1111

12-
# Apply the four managed rulesets using the current gh administrator credentials.
12+
# Apply the four managed rulesets and configured environment reviewers using the current gh administrator credentials.
1313
# @returns [Hash] The managed ruleset payloads after successful application.
1414
def apply
1515
require "bake/gem/github/project"
1616

17-
return Bake::Gem::GitHub::Project.new(context.root).apply
17+
Bake::Gem::GitHub::Project.new(context.root).apply
1818
end
1919

2020
# Update generated files in the working tree using config/release.yaml and the installed templates.
2121
# @returns [Array(String)] Changed paths relative to the repository root.
2222
def update
2323
require "bake/gem/github/setup"
2424

25-
return Bake::Gem::GitHub::Setup.new(context.root).update
25+
Bake::Gem::GitHub::Setup.new(context.root).update
2626
end

‎config/release.yaml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,5 @@ approvals: 2
1818
signing: true
1919
ruby: '3.4'
2020
environment: rubygems
21+
reviewers:
22+
- socketry/managers

‎context/getting-started.md‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,28 @@ Ownership, MFA, and signing bootstrap are manual setup steps. The plan reports e
5454

5555
When `release.cert` exists, setup enables certificate signing. Commit the public certificate and install its matching private key as `GEM_SIGNING_KEY`, either in the `rubygems` environment or as an organization secret available to the repository. The publisher checks certificate validity, key matching, and package signatures. Use `signing=false` during setup to disable certificate signing.
5656

57-
Ensure another maintainer can administer the repository and recover its RubyGems account and signing key. Keep the native PR review policy as the routine approval step; the environment does not need another reviewer gate.
57+
Ensure another maintainer can administer the repository and recover its RubyGems account and signing key.
58+
59+
## Authorize publishing
60+
61+
PR reviews approve the source changes. To require a release manager to authorize publication after merge, configure required reviewers on the `rubygems` environment. The publishing job waits for this separate approval before running.
62+
63+
Pass `reviewers=your-org/managers` to `gem:github:setup`, or add the reviewers to an existing `config/release.yaml`:
64+
65+
``` yaml
66+
reviewers:
67+
- your-org/managers
68+
```
69+
70+
Replace `your-org/managers` with your organization and team slug, for example `socketry/managers`. Individual user logins are also supported. Reviewers need at least read access to the repository. Setup resolves their GitHub IDs without changing repository access or team membership.
71+
72+
GitHub accepts one to six users or teams, and **one approval from any listed reviewer or team member is sufficient**. It does not support a minimum environment approval count. The default two PR approvals are independent of this publishing approval.
73+
74+
Create the environment and restrict its deployment branch as described above before running plan or apply with reviewers configured. The plan previews the current and desired environment settings. Apply replaces its reviewer list while preserving its wait timer, self-review prevention, administrator bypass setting, and deployment branch restrictions. Custom deployment protection rules are managed separately and are not modified. Reapplying an identical reviewer list leaves the environment unchanged.
75+
76+
Omitting `reviewers` leaves environment settings unmanaged, including any existing reviewer requirement. An empty list is rejected. To remove an existing requirement, change the environment settings explicitly in GitHub.
77+
78+
The RubyGems Trusted Publisher must explicitly require the `rubygems` environment; leaving that field blank would allow this trusted publisher to authenticate jobs without the environment approval. Keep administrator bypass enabled if administrators should be able to explicitly authorize publication without a reviewer. Environment approvals are available for public repositories on GitHub Free.
5879

5980
## Enable the policy
6081

@@ -65,7 +86,7 @@ bundle exec bake gem:github:setup:plan
6586
bundle exec bake gem:github:setup:apply
6687
```
6788

68-
Apply updates only the four managed rulesets and preserves unrelated rulesets. Other repository and organization protections still apply. Keep check names in `config/release.yaml` synchronized with the workflows, and apply updated rules after renamed jobs are available. Keep rebase merging and merge queues disabled for this process.
89+
Apply updates the four managed rulesets and, when configured, the existing environment's reviewer list. It preserves unrelated rulesets. Other repository and organization protections still apply. Keep check names in `config/release.yaml` synchronized with the workflows, and apply updated rules after renamed jobs are available. Keep rebase merging and merge queues disabled for this process.
6990

7091
## Prepare the first release PR
7192

@@ -75,7 +96,7 @@ From an up-to-date default branch:
7596
bundle exec bake gem:github:release:patch
7697
```
7798

78-
The task prepares, validates, pushes, and opens the release PR. Review its version and release notes, wait for CI, and merge under the repository's approval policy. The publish workflow builds the merged release, verifies and preserves the artifact, publishes to RubyGems, and finalizes the version tag and GitHub release.
99+
The task prepares, validates, pushes, and opens the release PR. Review its version and release notes, wait for CI, and merge under the repository's approval policy. If environment reviewers are configured, a release manager then approves the publishing job in GitHub Actions. The publish workflow builds the merged release, verifies and preserves the artifact, publishes to RubyGems, and finalizes the version tag and GitHub release.
79100

80101
See [Preparing Releases](../preparing-releases/index) for remote requests and stale-content refresh, [Verifying Releases](../verifying-releases/index) for artifact checks, and [Recovering Releases](../recovering-releases/index) when a workflow stops partway through.
81102

‎context/recovering-releases.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Use **Re-run all jobs** on the original publishing run, or:
1212
bundle exec bake gem:github:release:resume run=RUN_ID
1313
```
1414

15-
Rerunning keeps the original event identity. A retained artifact is downloaded and its source identity/digest checked. A matching registry version resumes tag/release finalization; different bytes or a conflicting tag stop. There is no automatic yank, retag, or rebuild of an already-published version. Registry propagation is retried every ten seconds for up to one minute; a digest or attestation mismatch fails immediately.
15+
Rerunning keeps the original event identity. GitHub may request publishing environment approval again. A retained artifact is downloaded and its source identity/digest checked. A matching registry version resumes tag/release finalization; different bytes or a conflicting tag stop. There is no automatic yank, retag, or rebuild of an already-published version. Registry propagation is retried every ten seconds for up to one minute; a digest or attestation mismatch fails immediately.
1616

1717
## Restore retained artifacts
1818

‎guides/getting-started/readme.md‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,28 @@ Ownership, MFA, and signing bootstrap are manual setup steps. The plan reports e
5454

5555
When `release.cert` exists, setup enables certificate signing. Commit the public certificate and install its matching private key as `GEM_SIGNING_KEY`, either in the `rubygems` environment or as an organization secret available to the repository. The publisher checks certificate validity, key matching, and package signatures. Use `signing=false` during setup to disable certificate signing.
5656

57-
Ensure another maintainer can administer the repository and recover its RubyGems account and signing key. Keep the native PR review policy as the routine approval step; the environment does not need another reviewer gate.
57+
Ensure another maintainer can administer the repository and recover its RubyGems account and signing key.
58+
59+
## Authorize publishing
60+
61+
PR reviews approve the source changes. To require a release manager to authorize publication after merge, configure required reviewers on the `rubygems` environment. The publishing job waits for this separate approval before running.
62+
63+
Pass `reviewers=your-org/managers` to `gem:github:setup`, or add the reviewers to an existing `config/release.yaml`:
64+
65+
``` yaml
66+
reviewers:
67+
- your-org/managers
68+
```
69+
70+
Replace `your-org/managers` with your organization and team slug, for example `socketry/managers`. Individual user logins are also supported. Reviewers need at least read access to the repository. Setup resolves their GitHub IDs without changing repository access or team membership.
71+
72+
GitHub accepts one to six users or teams, and **one approval from any listed reviewer or team member is sufficient**. It does not support a minimum environment approval count. The default two PR approvals are independent of this publishing approval.
73+
74+
Create the environment and restrict its deployment branch as described above before running plan or apply with reviewers configured. The plan previews the current and desired environment settings. Apply replaces its reviewer list while preserving its wait timer, self-review prevention, administrator bypass setting, and deployment branch restrictions. Custom deployment protection rules are managed separately and are not modified. Reapplying an identical reviewer list leaves the environment unchanged.
75+
76+
Omitting `reviewers` leaves environment settings unmanaged, including any existing reviewer requirement. An empty list is rejected. To remove an existing requirement, change the environment settings explicitly in GitHub.
77+
78+
The RubyGems Trusted Publisher must explicitly require the `rubygems` environment; leaving that field blank would allow this trusted publisher to authenticate jobs without the environment approval. Keep administrator bypass enabled if administrators should be able to explicitly authorize publication without a reviewer. Environment approvals are available for public repositories on GitHub Free.
5879

5980
## Enable the policy
6081

@@ -65,7 +86,7 @@ bundle exec bake gem:github:setup:plan
6586
bundle exec bake gem:github:setup:apply
6687
```
6788

68-
Apply updates only the four managed rulesets and preserves unrelated rulesets. Other repository and organization protections still apply. Keep check names in `config/release.yaml` synchronized with the workflows, and apply updated rules after renamed jobs are available. Keep rebase merging and merge queues disabled for this process.
89+
Apply updates the four managed rulesets and, when configured, the existing environment's reviewer list. It preserves unrelated rulesets. Other repository and organization protections still apply. Keep check names in `config/release.yaml` synchronized with the workflows, and apply updated rules after renamed jobs are available. Keep rebase merging and merge queues disabled for this process.
6990

7091
## Prepare the first release PR
7192

@@ -75,7 +96,7 @@ From an up-to-date default branch:
7596
bundle exec bake gem:github:release:patch
7697
```
7798

78-
The task prepares, validates, pushes, and opens the release PR. Review its version and release notes, wait for CI, and merge under the repository's approval policy. The publish workflow builds the merged release, verifies and preserves the artifact, publishes to RubyGems, and finalizes the version tag and GitHub release.
99+
The task prepares, validates, pushes, and opens the release PR. Review its version and release notes, wait for CI, and merge under the repository's approval policy. If environment reviewers are configured, a release manager then approves the publishing job in GitHub Actions. The publish workflow builds the merged release, verifies and preserves the artifact, publishes to RubyGems, and finalizes the version tag and GitHub release.
79100

80101
See [Preparing Releases](../preparing-releases/index) for remote requests and stale-content refresh, [Verifying Releases](../verifying-releases/index) for artifact checks, and [Recovering Releases](../recovering-releases/index) when a workflow stops partway through.
81102

‎guides/recovering-releases/readme.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Use **Re-run all jobs** on the original publishing run, or:
1212
bundle exec bake gem:github:release:resume run=RUN_ID
1313
```
1414

15-
Rerunning keeps the original event identity. A retained artifact is downloaded and its source identity/digest checked. A matching registry version resumes tag/release finalization; different bytes or a conflicting tag stop. There is no automatic yank, retag, or rebuild of an already-published version. Registry propagation is retried every ten seconds for up to one minute; a digest or attestation mismatch fails immediately.
15+
Rerunning keeps the original event identity. GitHub may request publishing environment approval again. A retained artifact is downloaded and its source identity/digest checked. A matching registry version resumes tag/release finalization; different bytes or a conflicting tag stop. There is no automatic yank, retag, or rebuild of an already-published version. Registry propagation is retried every ten seconds for up to one minute; a digest or attestation mismatch fails immediately.
1616

1717
## Restore retained artifacts
1818

‎lib/bake/gem/github/project.rb‎

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
require "bake/gem/release"
77
require "yaml"
88
require "tempfile"
9+
require "uri"
910
require_relative "setup"
1011

1112
module Bake
@@ -128,12 +129,13 @@ def inspect_release(number)
128129
end
129130

130131
# Return a read-only comparison of managed settings and current repository settings.
131-
# @returns [Hash] Desired rules, existing rules, environments, and expected Trusted Publisher settings. This does not verify RubyGems ownership or publisher configuration.
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.
132133
def doctor
133134
{
134135
desired_rules: Setup.rules(@config),
135136
existing_rules: api("rulesets?per_page=100"),
136137
environments: api("environments"),
138+
environment_changes: environment_changes,
137139
trusted_publisher: {
138140
repository_owner: @repository.split("/").first,
139141
repository_name: @repository.split("/").last,
@@ -143,29 +145,81 @@ def doctor
143145
}
144146
end
145147

146-
# 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.
147150
# @returns [Hash] The desired ruleset payloads after successful application.
148151
# @raises [RuntimeError] If more than one existing ruleset has a managed name.
149152
# @raises [Bake::Gem::CommandExecutionError] If an API operation fails; earlier updates may already have completed.
150153
def apply
154+
changes = environment_changes
151155
existing = api("rulesets?per_page=100")
152-
return Setup.rules(@config).each_value do |rule|
156+
rules = Setup.rules(@config)
157+
rules.each_value do |rule|
153158
matches = existing.select{|current| current.fetch("name") == rule.fetch(:name)}
154159
raise "Multiple rulesets match #{rule[:name]}." if matches.size > 1
155160
current = matches.first
156161
path = "repos/#{@repository}/rulesets"
157162
path += "/#{current.fetch('id')}" if current
158163

159-
Tempfile.create("release-rule") do |file|
160-
file.write(JSON.generate(rule))
161-
file.flush
162-
system("gh", "api", path, "--method", current ? "PUT" : "POST", "--input", file.path, chdir: @root)
163-
end
164+
write_api(path, rule, method: current ? "PUT" : "POST")
164165
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
165172
end
166173

167174
private
168175

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+
169223
# Find the repository's sole release PR, excluding forks.
170224
def find_release_pull_request(branch)
171225
response = readlines(

0 commit comments

Comments
 (0)