From 518d99e0f477ab841b1ffa4451a848cdc25eeb9c Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sun, 27 Sep 2026 23:15:52 +1300 Subject: [PATCH] Add RubyGems trusted publisher setup task. Signed-off-by: Samuel Williams --- bake/gem/github/setup.rb | 24 ++- context/getting-started.md | 20 ++- guides/getting-started/readme.md | 20 ++- lib/bake/gem/github/project.rb | 18 +- lib/bake/gem/github/trusted_publisher.rb | 124 ++++++++++++++ releases.md | 5 + test/bake/gem/github/project.rb | 51 ++++++ test/bake/gem/github/trusted_publisher.rb | 195 ++++++++++++++++++++++ 8 files changed, 445 insertions(+), 12 deletions(-) create mode 100644 lib/bake/gem/github/trusted_publisher.rb create mode 100644 test/bake/gem/github/trusted_publisher.rb diff --git a/bake/gem/github/setup.rb b/bake/gem/github/setup.rb index 9d6b727..0f2206f 100644 --- a/bake/gem/github/setup.rb +++ b/bake/gem/github/setup.rb @@ -4,9 +4,29 @@ # Copyright, 2026, by Samuel Williams. # Inspect external settings before applying the generated policy. +# @parameter publisher [Boolean] Authenticate to RubyGems and check the expected publisher registration. +# @parameter key [Symbol] An explicitly selected RubyGems API key name. +# @parameter otp [String] An optional RubyGems MFA code. # @returns [Hash] Desired and observed settings for review. -def plan - context.lookup("gem:github:doctor").call +def plan(publisher: false, key: nil, otp: nil) + result = context.lookup("gem:github:doctor").call + if publisher + require "bake/gem/github/trusted_publisher" + + result[:trusted_publisher_status] = Bake::Gem::GitHub::TrustedPublisher.load(context.root, key: key, otp: otp).status + end + + return result +end + +# Register the configured release workflow as a trusted publisher for an existing gem on RubyGems.org. +# @parameter key [Symbol] An explicitly selected RubyGems API key name. +# @parameter otp [String] An optional RubyGems MFA code. +# @returns [Hash] Whether the publisher was created and its API record. +def publisher(key: nil, otp: nil) + require "bake/gem/github/trusted_publisher" + + Bake::Gem::GitHub::TrustedPublisher.load(context.root, key: key, otp: otp).register end # Apply the four managed rulesets and configured environment reviewers using the current gh administrator credentials. diff --git a/context/getting-started.md b/context/getting-started.md index 3470c0a..998add7 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -48,9 +48,25 @@ The rules require two approvals by default, allow explicit administrator bypass, ## Configure publishing credentials -Create a `rubygems` GitHub environment restricted to the default branch. On RubyGems, an owner must configure a Trusted Publisher with the values printed by `gem:github:setup:plan`: the owner/repository, workflow filename **`release-publish.yaml`**, and environment **`rubygems`**. See [RubyGems Trusted Publishing](https://guides.rubygems.org/trusted-publishing/) for the account setup. +Create a `rubygems` GitHub environment restricted to the default branch. For an existing gem on RubyGems.org, run this task from the repository root as a gem owner: -Ownership, MFA, and signing bootstrap are manual setup steps. The plan reports expected RubyGems values; it does not verify ownership or publisher trust. Trusted Publishing supplies the publishing credential for each run, so a long-lived RubyGems API key is unnecessary. +``` bash +bundle exec bake gem:github:setup:publisher +``` + +The task reads the gem name from its gemspec and the owner/repository and environment from `config/release.yaml`. It registers the **`release-publish.yaml`** workflow with the configured environment (**`rubygems`** by default). An exact existing match is reused, so reruns do not create duplicate registrations. Other publishers are preserved; the task does not remove or replace them. + +Without an explicitly supplied API key, the task prompts for RubyGems credentials and any required MFA verification. It requests a key with only the `configure_trusted_publishers` scope, expiring after 15 minutes, and keeps it in memory. It does not read or overwrite your default saved push key. To use an existing key with this scope, supply `GEM_HOST_API_KEY` or select a named RubyGems credential with `key=publisher`. Registration and the optional plan check also accept `otp=123456` or `GEM_HOST_OTP_CODE` when needed. + +To check registration without creating a publisher: + +``` bash +bundle exec bake gem:github:setup:plan publisher=true +``` + +This adds `trusted_publisher_status` to the plan, including `configured` and the matching registration. The default plan only reports expected RubyGems values and does not authenticate to RubyGems. Authentication, ownership, and API failures stop the task rather than being reported as a missing registration. + +For a gem that has never been published, configure a pending publisher manually. Account ownership, MFA enrollment, the GitHub environment, and signing bootstrap remain manual setup steps. See [RubyGems Trusted Publishing](https://guides.rubygems.org/trusted-publishing/) for account setup. Trusted Publishing supplies the publishing credential for each release, so a long-lived RubyGems API key is unnecessary. 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. diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 3470c0a..998add7 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -48,9 +48,25 @@ The rules require two approvals by default, allow explicit administrator bypass, ## Configure publishing credentials -Create a `rubygems` GitHub environment restricted to the default branch. On RubyGems, an owner must configure a Trusted Publisher with the values printed by `gem:github:setup:plan`: the owner/repository, workflow filename **`release-publish.yaml`**, and environment **`rubygems`**. See [RubyGems Trusted Publishing](https://guides.rubygems.org/trusted-publishing/) for the account setup. +Create a `rubygems` GitHub environment restricted to the default branch. For an existing gem on RubyGems.org, run this task from the repository root as a gem owner: -Ownership, MFA, and signing bootstrap are manual setup steps. The plan reports expected RubyGems values; it does not verify ownership or publisher trust. Trusted Publishing supplies the publishing credential for each run, so a long-lived RubyGems API key is unnecessary. +``` bash +bundle exec bake gem:github:setup:publisher +``` + +The task reads the gem name from its gemspec and the owner/repository and environment from `config/release.yaml`. It registers the **`release-publish.yaml`** workflow with the configured environment (**`rubygems`** by default). An exact existing match is reused, so reruns do not create duplicate registrations. Other publishers are preserved; the task does not remove or replace them. + +Without an explicitly supplied API key, the task prompts for RubyGems credentials and any required MFA verification. It requests a key with only the `configure_trusted_publishers` scope, expiring after 15 minutes, and keeps it in memory. It does not read or overwrite your default saved push key. To use an existing key with this scope, supply `GEM_HOST_API_KEY` or select a named RubyGems credential with `key=publisher`. Registration and the optional plan check also accept `otp=123456` or `GEM_HOST_OTP_CODE` when needed. + +To check registration without creating a publisher: + +``` bash +bundle exec bake gem:github:setup:plan publisher=true +``` + +This adds `trusted_publisher_status` to the plan, including `configured` and the matching registration. The default plan only reports expected RubyGems values and does not authenticate to RubyGems. Authentication, ownership, and API failures stop the task rather than being reported as a missing registration. + +For a gem that has never been published, configure a pending publisher manually. Account ownership, MFA enrollment, the GitHub environment, and signing bootstrap remain manual setup steps. See [RubyGems Trusted Publishing](https://guides.rubygems.org/trusted-publishing/) for account setup. Trusted Publishing supplies the publishing credential for each release, so a long-lived RubyGems API key is unnecessary. 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. diff --git a/lib/bake/gem/github/project.rb b/lib/bake/gem/github/project.rb index 34b896e..572ea0c 100644 --- a/lib/bake/gem/github/project.rb +++ b/lib/bake/gem/github/project.rb @@ -181,12 +181,18 @@ def doctor 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, - workflow_filename: "release-publish.yaml", - environment: @config.fetch("environment"), - } + trusted_publisher: trusted_publisher, + } + end + + # Describe the RubyGems publisher for the reviewed release workflow. + # @returns [Hash] Repository, workflow filename, and environment settings. + def trusted_publisher + { + repository_owner: @repository.split("/").first, + repository_name: @repository.split("/").last, + workflow_filename: "release-publish.yaml", + environment: @config.fetch("environment"), } end diff --git a/lib/bake/gem/github/trusted_publisher.rb b/lib/bake/gem/github/trusted_publisher.rb new file mode 100644 index 0000000..49c7670 --- /dev/null +++ b/lib/bake/gem/github/trusted_publisher.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "json" +require "rubygems/gemcutter_utilities" +require "rubygems/user_interaction" +require "uri" +require_relative "project" + +module Bake + module Gem + module GitHub + # Registers the reviewed release workflow with RubyGems.org using its owner API. + class TrustedPublisher + include ::Gem::UserInteraction + include ::Gem::GemcutterUtilities + + # The RubyGems API scope for managing trusted publishers. + SCOPE = :configure_trusted_publishers + + # The provider type accepted by the RubyGems trusted publisher API. + TYPE = "OIDC::TrustedPublisher::GitHubAction" + + # Load the gem identity and reviewed publisher settings from the current project. + # @parameter root [String] The repository root, which must also be the working directory. + # @parameter options [Hash] Authentication options passed to {initialize}. + # @returns [TrustedPublisher] The configured RubyGems client. + def self.load(root, **options) + project = Project.new(root) + name = Helper.new(root).gemspec.name + + new(name, project.trusted_publisher, **options) + end + + # Configure an existing gem's expected GitHub Actions publisher. + # @parameter name [String] The gem name. + # @parameter settings [Hash] Repository owner/name, workflow filename, and environment. + # @parameter key [Symbol | Nil] An explicitly selected key in RubyGems credentials. + # @parameter otp [String | Nil] An optional MFA code; RubyGems can prompt when required. + def initialize(name, settings, key: nil, otp: nil) + @path = "api/v1/gems/#{URI.encode_www_form_component(name)}/trusted_publishers" + @settings = settings.transform_keys(&:to_s) + @options = {key: key, otp: otp} + @host = "https://rubygems.org" + @api_key = nil + end + + # @attribute [Hash] Options used by RubyGems authentication and MFA support. + attr_reader :options + + # Read the matching publisher without changing publisher configuration. + # @returns [Hash] Whether the publisher is configured and its existing API record, if any. + # @raises [RuntimeError] If RubyGems cannot list the gem's publishers. + def status + sign_in(scope: SCOPE) unless api_key + publishers = request(:get, expected: "200") + raise "Invalid RubyGems trusted publishers response." unless publishers.is_a?(Array) + publisher = publishers.find{|record| matching?(record)} + + return {configured: !publisher.nil?, publisher: publisher} + end + + # Register a missing publisher, preserving all existing publisher registrations. + # @returns [Hash] Whether registration was created and the matching API record. + # @raises [RuntimeError] If RubyGems rejects registration or returns different settings. + def register + current = status + return {created: false, publisher: current.fetch(:publisher)} if current.fetch(:configured) + + publisher = request(:post, expected: "201", payload: {trusted_publisher_type: TYPE, trusted_publisher: @settings}) + raise "RubyGems returned unexpected trusted publisher settings." unless matching?(publisher) + + return {created: true, publisher: publisher} + end + + # Use the session key or an explicitly supplied credential, leaving the default push key alone. + # @returns [String | Nil] The API key used for this session. + def api_key + @api_key || ENV["GEM_HOST_API_KEY"] || (options[:key] && verify_api_key(options[:key])) + end + + # Keep newly issued credentials in memory rather than changing the user's credentials file. + # @parameter host [String] The authenticated RubyGems host. + # @parameter key [String] The newly issued API key. + def set_api_key(host, key) + @api_key = key + end + + private + + def get_mfa_params(profile) + super.merge(expires_at: (Time.now.utc + 900).strftime("%Y-%m-%d %H:%M:%S UTC")) + end + + def matching?(record) + return false unless record.fetch("trusted_publisher_type") == TYPE + settings = record.fetch("trusted_publisher") + + # Generated publishing workflows run in the configured repository, not a reusable workflow repository: + @settings.all?{|key, value| settings[key] == value} && + settings["workflow_repository_owner"].nil? && settings["workflow_repository_name"].nil? + end + + def request(method, expected:, payload: nil) + response = rubygems_api_request(method, @path, scope: SCOPE) do |request| + request["Authorization"] = api_key + request["Accept"] = "application/json" + if payload + request["Content-Type"] = "application/json" + request.body = JSON.generate(payload) + end + end + unless response.code == expected + raise "RubyGems trusted publisher #{method.to_s.upcase} failed (HTTP #{response.code}): #{clean_text(response.body)}" + end + + JSON.parse(response.body) + end + end + end + end +end diff --git a/releases.md b/releases.md index 77a7416..ab1b23c 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,10 @@ # Releases +## Unreleased + + - Register RubyGems trusted publishers from the gemspec and release configuration with `gem:github:setup:publisher`, reusing existing registrations and RubyGems authentication/MFA support. + - Check publisher registration explicitly with `gem:github:setup:plan publisher=true`. + ## v0.5.0 - Support rebase merging by requiring each release PR to contain exactly one commit, while leaving ordinary PRs unrestricted. Generated rules allow merge, squash, and rebase methods according to repository settings. diff --git a/test/bake/gem/github/project.rb b/test/bake/gem/github/project.rb index 4fcb8a0..d59b343 100644 --- a/test/bake/gem/github/project.rb +++ b/test/bake/gem/github/project.rb @@ -5,6 +5,7 @@ require "bake/gem/github/repository_context" require "bake/gem/github/project_client" +require "bake/gem/github/trusted_publisher" describe Bake::Gem::GitHub::Project do include Bake::Gem::GitHub::RepositoryContext @@ -120,6 +121,7 @@ it "reports desired and observed settings without writing" do expect(Bake::Gem::GitHub::Project).to receive(:new).with(repository).and_return(project) + expect(Bake::Gem::GitHub::TrustedPublisher).not.to receive(:load) result = Bake::Context.load(repository).call("gem:github:setup:plan") expect(result[:desired_rules].keys).to be == %w[reviews checks history tags] @@ -128,6 +130,55 @@ expect(project.writes).to be == [] end + ["false", "true"].each do |enabled| + it "checks RubyGems only when explicitly requested", unique: enabled do + expect(Bake::Gem::GitHub::Project).to receive(:new).with(repository).and_return(project) + if enabled == "true" + publisher = Object.new + expect(Bake::Gem::GitHub::TrustedPublisher).to receive(:load).with(repository, key: :publisher, otp: "012345").and_return(publisher) + expect(publisher).to receive(:status).and_return({configured: true}) + else + expect(Bake::Gem::GitHub::TrustedPublisher).not.to receive(:load) + end + result = Bake::Context.load(repository).call("gem:github:setup:plan", "publisher=#{enabled}", "key=publisher", "otp=012345") + + expect(result[:trusted_publisher_status]).to be == (enabled == "true" ? {configured: true} : nil) + expect(project.writes).to be == [] + end + end + + it "registers a publisher through the setup task" do + publisher = Object.new + expect(Bake::Gem::GitHub::TrustedPublisher).to receive(:load).with(repository, key: :publisher, otp: "012345").and_return(publisher) + expect(publisher).to receive(:register).and_return({created: true}) + result = Bake::Context.load(repository).call("gem:github:setup:publisher", "key=publisher", "otp=012345") + + expect(result).to be == {created: true} + expect(project.requests).to be == [] + end + + it "loads the gem identity and publisher restrictions from the current project" do + config_path = File.join(repository, "config/release.yaml") + config = YAML.safe_load_file(config_path) + config.merge!("repository" => "another-org/another-repository", "environment" => "production") + File.write(config_path, YAML.dump(config)) + result = isolated_project(<<~'RUBY') + require "bake/gem/github/trusted_publisher" + class ConfiguredPublisher < Bake::Gem::GitHub::TrustedPublisher + def self.new(name, settings, **options) + {name: name, settings: settings, options: options} + end + end + ConfiguredPublisher.load(Dir.pwd, key: :publisher, otp: "012345") + RUBY + + expect(result).to be == { + name: "example", + settings: {repository_owner: "another-org", repository_name: "another-repository", workflow_filename: "release-publish.yaml", environment: "production"}, + options: {key: :publisher, otp: "012345"}, + } + end + it "creates missing managed rulesets while preserving unrelated rulesets" do rules << {"id" => 123, "name" => "Unrelated policy"} diff --git a/test/bake/gem/github/trusted_publisher.rb b/test/bake/gem/github/trusted_publisher.rb new file mode 100644 index 0000000..0467064 --- /dev/null +++ b/test/bake/gem/github/trusted_publisher.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "bake/gem/github/trusted_publisher" +require "rubygems/vendored_net_http" +require "time" + +describe Bake::Gem::GitHub::TrustedPublisher do + let(:settings) {{repository_owner: "socketry", repository_name: "example", workflow_filename: "release-publish.yaml", environment: "rubygems"}} + let(:client) {subject.new("example", settings, otp: "123456")} + let(:transport) {Object.new} + let(:requests) {[]} + let(:responses) {[]} + let(:record) {{"id" => 1, "trusted_publisher_type" => subject::TYPE, "trusted_publisher" => settings.transform_keys(&:to_s)}} + let(:path) {"/api/v1/gems/example/trusted_publishers"} + + def response(code, body) + result = ::Gem::Net::HTTPResponse::CODE_TO_OBJ.fetch(code).new("1.1", code, "") + mock(result) do |wrapper| + wrapper.replace(:body){body} + end + + return result + end + + before do + client.set_api_key(client.host, "test-token") + mock(::Gem::RemoteFetcher) do |wrapper| + wrapper.replace(:fetcher){transport} + end + mock(transport) do |wrapper| + wrapper.replace(:request) do |uri, method, &block| + expect(uri.scheme).to be == "https" + expect(uri.host).to be == "rubygems.org" + request = method.new(uri.request_uri) + block.call(request) + requests << request + responses.shift or raise "Unexpected request: #{request.method} #{request.path}" + end + end + end + + it "registers the configured publisher once and recognizes it on reruns" do + responses.push(response("200", "[]"), response("201", JSON.generate(record)), response("200", JSON.generate([record]))) + + expect(client.register).to be == {created: true, publisher: record} + expect(client.register).to be == {created: false, publisher: record} + expect(requests.map(&:method)).to be == ["GET", "POST", "GET"] + requests.each do |request| + expect(request.path).to be == path + expect(request["Authorization"]).to be == "test-token" + expect(request["OTP"]).to be == "123456" + expect(request["Accept"]).to be == "application/json" + end + expect(requests[1]["Content-Type"]).to be == "application/json" + expect(JSON.parse(requests[1].body)).to be == record.reject{|key, _| key == "id"} + end + + it "reports a missing publisher without creating it" do + responses << response("200", "[]") + + expect(client.status).to be == {configured: false, publisher: nil} + expect(requests.map(&:method)).to be == ["GET"] + end + + it "preserves unrelated registrations when adding the expected publisher" do + unrelated = record.merge("id" => 2, "trusted_publisher_type" => "AnotherProvider") + responses.push(response("200", JSON.generate([unrelated])), response("201", JSON.generate(record))) + + expect(client.register).to be == {created: true, publisher: record} + expect(requests.map(&:method)).to be == ["GET", "POST"] + end + + { + "repository_owner" => "another-owner", + "repository_name" => "another-repository", + "workflow_filename" => "push_gem.yml", + "environment" => nil, + "workflow_repository_owner" => "socketry", + "workflow_repository_name" => "workflows", + }.each do |key, value| + it "requires matching repository, workflow, and environment restrictions", unique: key do + record.fetch("trusted_publisher")[key] = value + responses << response("200", JSON.generate([record])) + + expect(client.status).to be == {configured: false, publisher: nil} + end + end + + ["401", "403", "404", "500"].each do |code| + it "does not create a publisher when listing fails", unique: code do + responses << response(code, "Request rejected") + + expect{client.register}.to raise_exception(RuntimeError, message: be =~ /GET failed \(HTTP #{code}\): Request rejected/) + expect(requests.map(&:method)).to be == ["GET"] + end + end + + it "reports registration errors" do + responses.push(response("200", "[]"), response("422", "Invalid publisher")) + + expect{client.register}.to raise_exception(RuntimeError, message: be =~ /POST failed \(HTTP 422\): Invalid publisher/) + end + + it "rejects a successful registration with unexpected settings" do + record.fetch("trusted_publisher")["environment"] = nil + responses.push(response("200", "[]"), response("201", JSON.generate(record))) + + expect{client.register}.to raise_exception(RuntimeError, message: be =~ /unexpected trusted publisher settings/) + end + + it "rejects a malformed publisher list" do + responses << response("200", "{}") + + expect{client.register}.to raise_exception(RuntimeError, message: be =~ /Invalid RubyGems trusted publishers response/) + expect(requests.map(&:method)).to be == ["GET"] + end + + it "rejects invalid JSON without attempting registration" do + responses << response("200", "not JSON") + + expect{client.register}.to raise_exception(JSON::ParserError) + expect(requests.map(&:method)).to be == ["GET"] + end + + it "uses RubyGems MFA prompts to retry a challenged request" do + responses.push( + response("401", "You have enabled multifactor authentication"), + response("404", "WebAuthn unavailable"), + response("200", JSON.generate([record])) + ) + expect(client).to receive(:say) + expect(client).to receive(:ask).with("Code: ").and_return("654321") + + expect(client.status).to be == {configured: true, publisher: record} + expect(requests.map(&:path)).to be == [path, "/api/v1/webauthn_verification", path] + expect(requests.last["OTP"]).to be == "654321" + end + + with "authentication" do + before do + client.set_api_key(client.host, nil) + mock(ENV) do |wrapper| + wrapper.wrap(:[]) do |original, key| + key == "GEM_HOST_API_KEY" ? nil : original.call(key) + end + end + end + + it "uses an explicitly supplied environment key" do + expect(ENV).to receive(:[]).with("GEM_HOST_API_KEY").and_return("environment-token") + + expect(client.api_key).to be == "environment-token" + end + + it "uses an explicitly selected saved key" do + client.options[:key] = :publisher + expect(::Gem.configuration).to receive(:api_keys).twice.and_return({publisher: "named-token"}) + + expect(client.api_key).to be == "named-token" + end + + it "creates a scoped, expiring session key without reading or writing saved credentials" do + expect(::Gem.configuration).not.to receive(:api_keys) + expect(::Gem.configuration).not.to receive(:rubygems_api_key=) + expect(::Gem.configuration).not.to receive(:set_api_key) + mock(client) do |wrapper| + wrapper.replace(:say){} + wrapper.replace(:ask){"maintainer@example.com"} + wrapper.replace(:ask_for_password){"test-password"} + end + responses.push( + response("200", "---\nmfa: ui_and_api\n"), + response("200", "session-token"), + response("200", "[]") + ) + started = Time.now.utc + + expect(client.status).to be == {configured: false, publisher: nil} + expect(requests.map(&:path)).to be == ["/api/v1/profile/me.yaml", "/api/v1/api_key", path] + params = URI.decode_www_form(requests[1].body).to_h + + expect(params.keys.sort).to be == ["configure_trusted_publishers", "expires_at", "name"] + expect(params.fetch("configure_trusted_publishers")).to be == "true" + expires_at = Time.strptime(params.fetch("expires_at"), "%Y-%m-%d %H:%M:%S %Z") + + expect(expires_at).to be >= started - 1 + 900 + expect(expires_at).to be <= Time.now.utc + 900 + expect(requests.last["Authorization"]).to be == "session-token" + expect(client.api_key).to be == "session-token" + end + end +end