From b31ec19e1a51d69a759f8edf78c3e56cc533e36b Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 22 Sep 2026 14:18:33 +1200 Subject: [PATCH] Configure publishing environment reviewers during setup. Signed-off-by: Samuel Williams --- bake/gem/github.rb | 10 +- bake/gem/github/setup.rb | 6 +- config/release.yaml | 2 + context/getting-started.md | 27 +++- context/recovering-releases.md | 2 +- guides/getting-started/readme.md | 27 +++- guides/recovering-releases/readme.md | 2 +- lib/bake/gem/github/project.rb | 70 ++++++++-- lib/bake/gem/github/setup.rb | 14 +- releases.md | 1 + test/bake/gem/github/project/environment.rb | 134 ++++++++++++++++++++ test/bake/gem/github/setup.rb | 21 ++- test/bake/gem/github/setup/update.rb | 1 + 13 files changed, 292 insertions(+), 25 deletions(-) create mode 100644 test/bake/gem/github/project/environment.rb diff --git a/bake/gem/github.rb b/bake/gem/github.rb index 8fe5b25..1a52866 100644 --- a/bake/gem/github.rb +++ b/bake/gem/github.rb @@ -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" @@ -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 diff --git a/bake/gem/github/setup.rb b/bake/gem/github/setup.rb index bfa8376..9d6b727 100644 --- a/bake/gem/github/setup.rb +++ b/bake/gem/github/setup.rb @@ -9,12 +9,12 @@ 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. @@ -22,5 +22,5 @@ def apply 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 diff --git a/config/release.yaml b/config/release.yaml index c7e0244..084f155 100644 --- a/config/release.yaml +++ b/config/release.yaml @@ -18,3 +18,5 @@ approvals: 2 signing: true ruby: '3.4' environment: rubygems +reviewers: +- socketry/managers diff --git a/context/getting-started.md b/context/getting-started.md index 7e32e29..fea84ac 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -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 @@ -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 @@ -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. diff --git a/context/recovering-releases.md b/context/recovering-releases.md index 5f43834..bda94ad 100644 --- a/context/recovering-releases.md +++ b/context/recovering-releases.md @@ -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 diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 7e32e29..fea84ac 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -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 @@ -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 @@ -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. diff --git a/guides/recovering-releases/readme.md b/guides/recovering-releases/readme.md index 5f43834..bda94ad 100644 --- a/guides/recovering-releases/readme.md +++ b/guides/recovering-releases/readme.md @@ -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 diff --git a/lib/bake/gem/github/project.rb b/lib/bake/gem/github/project.rb index 8d49a63..25393f5 100644 --- a/lib/bake/gem/github/project.rb +++ b/lib/bake/gem/github/project.rb @@ -6,6 +6,7 @@ require "bake/gem/release" require "yaml" require "tempfile" +require "uri" require_relative "setup" module Bake @@ -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, @@ -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( diff --git a/lib/bake/gem/github/setup.rb b/lib/bake/gem/github/setup.rb index e2d8777..d9dd766 100644 --- a/lib/bake/gem/github/setup.rb +++ b/lib/bake/gem/github/setup.rb @@ -23,11 +23,12 @@ def initialize(root) # @parameter branch [String] The default branch receiving release PRs. # @parameter checks [Array(String)] Required CI job names; release validation is added automatically. # @parameter approvals [Integer] Required approvals, between one and six. + # @parameter reviewers [Array(String) | Nil] Publishing environment reviewers, as user logins or organization/team names. Nil leaves environment settings unmanaged. # @parameter signing [Boolean] Whether publishing requires the certificate and matching private key. # @parameter ruby [String] The Ruby version used by release workflows. # @returns [Array(String)] Generated paths relative to the repository root. # @raises [RuntimeError] If configuration is invalid or an existing generated file differs. - def generate(repository:, branch: "main", checks:, approvals: 2, signing: File.file?(File.join(@root, "release.cert")), ruby: "3.4") + def generate(repository:, branch: "main", checks:, approvals: 2, reviewers: nil, signing: File.file?(File.join(@root, "release.cert")), ruby: "3.4") raise "Expected owner/repository." unless repository.match?(/\A[\w.-]+\/[\w.-]+\z/) raise "Unsupported branch name." unless branch.match?(/\A[\w.\/-]+\z/) raise "Select the required CI check names." if checks.empty? @@ -43,6 +44,7 @@ def generate(repository:, branch: "main", checks:, approvals: 2, signing: File.f "ruby" => ruby, "environment" => "rubygems", } + config["reviewers"] = reviewers unless reviewers.nil? files = render(config) conflicts = files.keys.select do |name| @@ -119,6 +121,15 @@ def self.rules(config) } end + # Validate an explicit list of publishing environment reviewers. + # @parameter reviewers [Array(String)] One to six user logins or organization/team names. + # @raises [ArgumentError] If the list is empty, too long, or contains invalid names. + def self.validate_reviewers(reviewers) + unless reviewers.is_a?(Array) && (1..6).include?(reviewers.size) && reviewers.all?{|name| name.is_a?(String) && name.match?(/\A[\w-]+(?:\/[\w-]+)?\z/)} + raise ArgumentError, "Specify one to six environment reviewers as user logins or organization/team names." + end + end + private def write(files) @@ -134,6 +145,7 @@ def write(files) end def render(config) + self.class.validate_reviewers(config["reviewers"]) if config.key?("reviewers") branch = config.fetch("branch") ruby = config.fetch("ruby") signing = config.fetch("signing") diff --git a/releases.md b/releases.md index 90c3759..500e53d 100644 --- a/releases.md +++ b/releases.md @@ -2,6 +2,7 @@ ## Unreleased + - Configure publishing environment reviewers through release setup while preserving existing environment protections. - Stop generating `.github/releasing.md`; release instructions are maintained in the shared guide and agent context. - Resume interrupted release preparation and explicitly refresh stale release PRs while preserving their previous commits. - Preserve all release files in one archive before individual asset uploads, so reruns can recover interrupted drafts. diff --git a/test/bake/gem/github/project/environment.rb b/test/bake/gem/github/project/environment.rb new file mode 100644 index 0000000..923b041 --- /dev/null +++ b/test/bake/gem/github/project/environment.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "bake/gem/github/project_client" +require "sus/fixtures/temporary_directory_context" + +describe Bake::Gem::GitHub::Project do + include Sus::Fixtures::TemporaryDirectoryContext + + let(:project) {Bake::Gem::GitHub::ProjectClient.new(root)} + let(:environment) do + { + "can_admins_bypass" => true, + "deployment_branch_policy" => {"protected_branches" => false, "custom_branch_policies" => true}, + "protection_rules" => [{"type" => "branch_policy"}], + } + end + + before do + Bake::Gem::GitHub::Setup.new(root).generate(repository: "socketry/example", checks: ["Tests"], reviewers: ["socketry/managers"]) + project.responses["repos/socketry/example/rulesets?per_page=100"] = [] + project.responses["repos/socketry/example/environments"] = {"environments" => [environment]} + project.responses["repos/socketry/example/environments/rubygems"] = environment + project.responses["orgs/socketry/teams/managers"] = {"id" => 123} + end + + with "#doctor" do + it "previews the resolved team and preserves environment defaults without writing" do + changes = project.doctor.fetch(:environment_changes) + + expect(changes.fetch(:name)).to be == "rubygems" + expect(changes.fetch(:current).fetch(:reviewers)).to be == [] + expect(changes.fetch(:desired)).to be == { + wait_timer: 0, + prevent_self_review: false, + can_admins_bypass: true, + deployment_branch_policy: environment.fetch("deployment_branch_policy"), + reviewers: [{type: "Team", id: 123}], + } + expect(project.writes).to be == [] + end + + it "resolves individual reviewers as well as teams" do + project.config["reviewers"] << "ioquatix" + project.responses["users/ioquatix"] = {"id" => 456} + + expect(project.doctor.dig(:environment_changes, :desired, :reviewers)).to be == [{type: "Team", id: 123}, {type: "User", id: 456}] + end + end + + with "#apply" do + it "updates reviewers without weakening existing environment protections" do + environment["can_admins_bypass"] = false + environment["protection_rules"] += [ + {"type" => "wait_timer", "wait_timer" => 30}, + {"type" => "required_reviewers", "prevent_self_review" => true, "reviewers" => [{"type" => "User", "reviewer" => {"id" => 456}}]}, + ] + project.apply + + expect(project.writes.last).to be == { + "wait_timer" => 30, + "prevent_self_review" => true, + "can_admins_bypass" => false, + "deployment_branch_policy" => environment.fetch("deployment_branch_policy"), + "reviewers" => [{"type" => "Team", "id" => 123}], + } + expect(project.requests.last[0, 5]).to be == ["gh", "api", "repos/socketry/example/environments/rubygems", "--method", "PUT"] + expect(project.writes.size).to be == 5 + end + + it "preserves an unrestricted branch policy and enabled administrator bypass" do + environment["deployment_branch_policy"] = nil + project.apply + + expect(project.writes.last).to have_keys("deployment_branch_policy" => be_nil, "can_admins_bypass" => be == true) + end + + it "does not update an environment which already has the configured reviewers" do + environment["protection_rules"] << {"type" => "required_reviewers", "prevent_self_review" => false, "reviewers" => [{"type" => "Team", "reviewer" => {"id" => 123}}]} + project.apply + + expect(project.writes.size).to be == 4 + expect(project.writes.all?{|payload| payload.key?("rules")}).to be == true + end + + it "leaves environment settings unmanaged when reviewers are omitted" do + project.config.delete("reviewers") + project.apply + + expect(project.requests.any?{|request| request.fetch(2).include?("environments")}).to be == false + expect(project.writes.size).to be == 4 + expect(project.doctor.fetch(:environment_changes)).to be_nil + end + + it "refuses invalid reviewers before making external requests" do + project.config["reviewers"] = [] + + expect{project.apply}.to raise_exception(ArgumentError, message: be =~ /one to six environment reviewers/) + expect(project.requests).to be == [] + expect(project.writes).to be == [] + end + + it "refuses teams from another organization before writing" do + project.config["reviewers"] = ["other/managers"] + + expect{project.apply}.to raise_exception(RuntimeError, message: be =~ /Reviewer team must belong to socketry/) + expect(project.writes).to be == [] + end + + ["repos/socketry/example/environments/rubygems", "orgs/socketry/teams/managers"].each do |path| + it "stops before writing if an environment or reviewer cannot be read", unique: path do + mock(project) do |mock| + mock.wrap(:readlines) do |original, *arguments, **options| + raise "GitHub request failed" if arguments.fetch(2) == path + original.call(*arguments, **options) + end + end + + expect{project.apply}.to raise_exception(RuntimeError, message: be =~ /GitHub request failed/) + expect(project.writes).to be == [] + end + end + + it "escapes the configured environment name in API requests" do + project.config["environment"] = "release / gems" + project.responses["repos/socketry/example/environments/release%20%2F%20gems"] = environment + project.apply + + expect(project.requests.last.fetch(2)).to be == "repos/socketry/example/environments/release%20%2F%20gems" + end + end +end diff --git a/test/bake/gem/github/setup.rb b/test/bake/gem/github/setup.rb index a0b1ef0..069cf97 100644 --- a/test/bake/gem/github/setup.rb +++ b/test/bake/gem/github/setup.rb @@ -20,11 +20,12 @@ def generate registry = Bake::Registry::Aggregate.new registry.append_path(::Gem.loaded_specs.fetch("bake-gem-github").full_gem_path) context = Bake::Context.new(registry, root) - context.call("gem:github:setup", "repository=socketry/example", "branch=main", "checks=Tests,RuboCop", "signing=false", "approvals=2") + context.call("gem:github:setup", "repository=socketry/example", "branch=main", "checks=Tests,RuboCop", "signing=false", "approvals=2", "reviewers=socketry/managers,ioquatix") config = YAML.safe_load_file(File.join(root, "config/release.yaml")) expect(config.fetch("signing")).to be == false expect(config.fetch("approvals")).to be == 2 + expect(config.fetch("reviewers")).to be == ["socketry/managers", "ioquatix"] %w[resolve build publish resume patch minor major].each do |name| expect(context.lookup("gem:github:release:#{name}")).not.to be_nil end @@ -34,6 +35,7 @@ def generate paths = generate expect(generate).to be == paths + expect(YAML.safe_load_file(File.join(root, "config/release.yaml"))).not.to have_keys("reviewers") expect(File).not.to be(:exist?, File.join(root, ".github/releasing.md")) expect(paths.grep(/workflows/).size).to be == 3 paths.grep(/workflows/).each do |path| @@ -47,6 +49,23 @@ def generate expect(checks.dig("bypass_actors", 0, "bypass_mode")).to be == "pull_request" end + [nil, [], "socketry/managers", [nil], [""], ["@socketry/managers"], ["socketry/managers/other"], ["invalid?name"], Array.new(7, "ioquatix")].each do |reviewers| + it "rejects invalid reviewer configuration before generating files", unique: reviewers.inspect do + FileUtils.mkdir_p(File.join(root, "config")) + File.write(File.join(root, "config/release.yaml"), YAML.dump("schema" => 1, "reviewers" => reviewers)) + + expect{setup.update}.to raise_exception(ArgumentError, message: be =~ /one to six environment reviewers/) + expect(File).not.to be(:exist?, File.join(root, ".github")) + end + end + + it "rejects an empty reviewer list when generating setup" do + expect do + setup.generate(repository: "socketry/example", checks: ["Tests"], reviewers: []) + end.to raise_exception(ArgumentError, message: be =~ /one to six environment reviewers/) + expect(Dir.children(root)).to be == [] + end + it "keeps unmerged validation read-only and retains artifacts before credentials" do generate validation = File.read(File.join(root, ".github/workflows/release-validate.yaml")) diff --git a/test/bake/gem/github/setup/update.rb b/test/bake/gem/github/setup/update.rb index 5af94c4..f068b6b 100644 --- a/test/bake/gem/github/setup/update.rb +++ b/test/bake/gem/github/setup/update.rb @@ -22,6 +22,7 @@ path = File.join(repository, "config/release.yaml") config = YAML.safe_load_file(path) config["approvals"] = 3 + config["reviewers"] = ["socketry/managers"] config["checks"] << "New check" File.write(path, YAML.dump(config)) registry = Bake::Registry::Aggregate.new