Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions bake/gem/github.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@
# @parameter repository [String] Canonical owner/repository; discovered through GitHub when omitted.
# @parameter branch [String] Default branch name; discovered through GitHub when omitted.
# @parameter approvals [Integer] Number of approving reviews.
# @parameter reviewers [Array(String)] Publishing environment reviewers: user logins or organization/team names. Omit to leave environment settings unmanaged.
# @parameter signing [Boolean] Require certificate signing; when omitted, enable it if `release.cert` exists.
# @parameter ruby [String] Ruby version for release workflows.
# @returns [Array(String)] Generated paths relative to the repository root.
def setup(checks:, repository: nil, branch: nil, approvals: 2, signing: nil, ruby: "3.4")
def setup(checks:, repository: nil, branch: nil, approvals: 2, reviewers: nil, signing: nil, ruby: "3.4")
require "bake/gem/github/setup"
require "bake/gem/shell"

Expand All @@ -27,17 +28,18 @@ def setup(checks:, repository: nil, branch: nil, approvals: 2, signing: nil, rub
branch: branch || remote.fetch("defaultBranchRef").fetch("name"),
checks: checks,
approvals: approvals,
reviewers: reviewers,
ruby: ruby,
}
options[:signing] = signing unless signing.nil?

return Bake::Gem::GitHub::Setup.new(context.root).generate(**options)
Bake::Gem::GitHub::Setup.new(context.root).generate(**options)
end

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

return Bake::Gem::GitHub::Project.new(context.root).doctor
Bake::Gem::GitHub::Project.new(context.root).doctor
end
6 changes: 3 additions & 3 deletions bake/gem/github/setup.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,18 @@ def plan
context.lookup("gem:github:doctor").call
end

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

return Bake::Gem::GitHub::Project.new(context.root).apply
Bake::Gem::GitHub::Project.new(context.root).apply
end

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

return Bake::Gem::GitHub::Setup.new(context.root).update
Bake::Gem::GitHub::Setup.new(context.root).update
end
2 changes: 2 additions & 0 deletions config/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,5 @@ approvals: 2
signing: true
ruby: '3.4'
environment: rubygems
reviewers:
- socketry/managers
27 changes: 24 additions & 3 deletions context/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,28 @@ Ownership, MFA, and signing bootstrap are manual setup steps. The plan reports e

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.

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.
Ensure another maintainer can administer the repository and recover its RubyGems account and signing key.

## Authorize publishing

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.

Pass `reviewers=your-org/managers` to `gem:github:setup`, or add the reviewers to an existing `config/release.yaml`:

``` yaml
reviewers:
- your-org/managers
```

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.

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.

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.

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.

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.

## Enable the policy

Expand All @@ -65,7 +86,7 @@ bundle exec bake gem:github:setup:plan
bundle exec bake gem:github:setup:apply
```

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.
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.

## Prepare the first release PR

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

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.
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.

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.

Expand Down
2 changes: 1 addition & 1 deletion context/recovering-releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Use **Re-run all jobs** on the original publishing run, or:
bundle exec bake gem:github:release:resume run=RUN_ID
```

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.
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.

## Restore retained artifacts

Expand Down
27 changes: 24 additions & 3 deletions guides/getting-started/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,28 @@ Ownership, MFA, and signing bootstrap are manual setup steps. The plan reports e

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.

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.
Ensure another maintainer can administer the repository and recover its RubyGems account and signing key.

## Authorize publishing

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.

Pass `reviewers=your-org/managers` to `gem:github:setup`, or add the reviewers to an existing `config/release.yaml`:

``` yaml
reviewers:
- your-org/managers
```

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.

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.

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.

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.

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.

## Enable the policy

Expand All @@ -65,7 +86,7 @@ bundle exec bake gem:github:setup:plan
bundle exec bake gem:github:setup:apply
```

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.
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.

## Prepare the first release PR

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

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.
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.

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.

Expand Down
2 changes: 1 addition & 1 deletion guides/recovering-releases/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Use **Re-run all jobs** on the original publishing run, or:
bundle exec bake gem:github:release:resume run=RUN_ID
```

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.
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.

## Restore retained artifacts

Expand Down
70 changes: 62 additions & 8 deletions lib/bake/gem/github/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require "bake/gem/release"
require "yaml"
require "tempfile"
require "uri"
require_relative "setup"

module Bake
Expand Down Expand Up @@ -128,12 +129,13 @@ def inspect_release(number)
end

# Return a read-only comparison of managed settings and current repository settings.
# @returns [Hash] Desired rules, existing rules, environments, and expected Trusted Publisher settings. This does not verify RubyGems ownership or publisher configuration.
# @returns [Hash] Desired rules, existing rules, environments, optional environment changes, and expected Trusted Publisher settings. This does not verify RubyGems ownership or publisher configuration.
def doctor
{
desired_rules: Setup.rules(@config),
existing_rules: api("rulesets?per_page=100"),
environments: api("environments"),
environment_changes: environment_changes,
trusted_publisher: {
repository_owner: @repository.split("/").first,
repository_name: @repository.split("/").last,
Expand All @@ -143,29 +145,81 @@ def doctor
}
end

# Apply only the named rulesets generated by setup. Invoke after reviewing doctor output.
# Apply the named rulesets and configured reviewers for an existing environment. Invoke after reviewing doctor output.
# Preserves the environment's wait timer, self-review prevention, administrator bypass, and branch restrictions.
# @returns [Hash] The desired ruleset payloads after successful application.
# @raises [RuntimeError] If more than one existing ruleset has a managed name.
# @raises [Bake::Gem::CommandExecutionError] If an API operation fails; earlier updates may already have completed.
def apply
changes = environment_changes
existing = api("rulesets?per_page=100")
return Setup.rules(@config).each_value do |rule|
rules = Setup.rules(@config)
rules.each_value do |rule|
matches = existing.select{|current| current.fetch("name") == rule.fetch(:name)}
raise "Multiple rulesets match #{rule[:name]}." if matches.size > 1
current = matches.first
path = "repos/#{@repository}/rulesets"
path += "/#{current.fetch('id')}" if current

Tempfile.create("release-rule") do |file|
file.write(JSON.generate(rule))
file.flush
system("gh", "api", path, "--method", current ? "PUT" : "POST", "--input", file.path, chdir: @root)
end
write_api(path, rule, method: current ? "PUT" : "POST")
end

if changes && changes.fetch(:current) != changes.fetch(:desired)
write_api("repos/#{@repository}/#{environment_path}", changes.fetch(:desired), method: "PUT")
end

return rules
end

private

def environment_path
"environments/#{URI.encode_www_form_component(@config.fetch('environment')).gsub('+', '%20')}"
end

# Resolve reviewers before making any changes, and preserve unrelated environment settings.
def environment_changes
return nil unless @config.key?("reviewers")
Setup.validate_reviewers(@config["reviewers"])

environment = api(environment_path)
protections = environment.fetch("protection_rules").to_h{|rule| [rule.fetch("type"), rule]}
reviews = protections.fetch("required_reviewers", {})
current = {
wait_timer: protections.fetch("wait_timer", {}).fetch("wait_timer", 0),
prevent_self_review: reviews.fetch("prevent_self_review", false),
can_admins_bypass: environment.fetch("can_admins_bypass"),
deployment_branch_policy: environment.fetch("deployment_branch_policy"),
reviewers: reviews.fetch("reviewers", []).map{|entry| {type: entry.fetch("type"), id: entry.fetch("reviewer").fetch("id")}},
}
reviewers = @config.fetch("reviewers").map{|name| resolve_reviewer(name)}

return {name: @config.fetch("environment"), current: current, desired: current.merge(reviewers: reviewers)}
end

def resolve_reviewer(name)
if name.include?("/")
organization, team = name.split("/", 2)
raise "Reviewer team must belong to #{@repository.split('/').first}." unless organization.casecmp?(@repository.split("/").first)
path = "orgs/#{organization}/teams/#{team}"
type = "Team"
else
path = "users/#{name}"
type = "User"
end
response = JSON.parse(readlines("gh", "api", path, chdir: @root).join)

return {type: type, id: response.fetch("id")}
end

def write_api(path, payload, method:)
return Tempfile.create("release-settings") do |file|
file.write(JSON.generate(payload))
file.flush
system("gh", "api", path, "--method", method, "--input", file.path, chdir: @root)
end
end

# Find the repository's sole release PR, excluding forks.
def find_release_pull_request(branch)
response = readlines(
Expand Down
Loading
Loading