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
24 changes: 22 additions & 2 deletions bake/gem/github/setup.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 18 additions & 2 deletions context/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
20 changes: 18 additions & 2 deletions guides/getting-started/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
18 changes: 12 additions & 6 deletions lib/bake/gem/github/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
124 changes: 124 additions & 0 deletions lib/bake/gem/github/trusted_publisher.rb
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions releases.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
51 changes: 51 additions & 0 deletions test/bake/gem/github/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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"}

Expand Down
Loading
Loading