Skip to content

Commit 7600c0f

Browse files
Add RubyGems trusted publisher setup task. (#35)
1 parent 9a0d1b9 commit 7600c0f

8 files changed

Lines changed: 445 additions & 12 deletions

File tree

‎bake/gem/github/setup.rb‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,29 @@
44
# Copyright, 2026, by Samuel Williams.
55

66
# Inspect external settings before applying the generated policy.
7+
# @parameter publisher [Boolean] Authenticate to RubyGems and check the expected publisher registration.
8+
# @parameter key [Symbol] An explicitly selected RubyGems API key name.
9+
# @parameter otp [String] An optional RubyGems MFA code.
710
# @returns [Hash] Desired and observed settings for review.
8-
def plan
9-
context.lookup("gem:github:doctor").call
11+
def plan(publisher: false, key: nil, otp: nil)
12+
result = context.lookup("gem:github:doctor").call
13+
if publisher
14+
require "bake/gem/github/trusted_publisher"
15+
16+
result[:trusted_publisher_status] = Bake::Gem::GitHub::TrustedPublisher.load(context.root, key: key, otp: otp).status
17+
end
18+
19+
return result
20+
end
21+
22+
# Register the configured release workflow as a trusted publisher for an existing gem on RubyGems.org.
23+
# @parameter key [Symbol] An explicitly selected RubyGems API key name.
24+
# @parameter otp [String] An optional RubyGems MFA code.
25+
# @returns [Hash] Whether the publisher was created and its API record.
26+
def publisher(key: nil, otp: nil)
27+
require "bake/gem/github/trusted_publisher"
28+
29+
Bake::Gem::GitHub::TrustedPublisher.load(context.root, key: key, otp: otp).register
1030
end
1131

1232
# Apply the four managed rulesets and configured environment reviewers using the current gh administrator credentials.

‎context/getting-started.md‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,25 @@ The rules require two approvals by default, allow explicit administrator bypass,
4848

4949
## Configure publishing credentials
5050

51-
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.
51+
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:
5252

53-
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.
53+
``` bash
54+
bundle exec bake gem:github:setup:publisher
55+
```
56+
57+
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.
58+
59+
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.
60+
61+
To check registration without creating a publisher:
62+
63+
``` bash
64+
bundle exec bake gem:github:setup:plan publisher=true
65+
```
66+
67+
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.
68+
69+
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.
5470

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

‎guides/getting-started/readme.md‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,25 @@ The rules require two approvals by default, allow explicit administrator bypass,
4848

4949
## Configure publishing credentials
5050

51-
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.
51+
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:
5252

53-
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.
53+
``` bash
54+
bundle exec bake gem:github:setup:publisher
55+
```
56+
57+
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.
58+
59+
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.
60+
61+
To check registration without creating a publisher:
62+
63+
``` bash
64+
bundle exec bake gem:github:setup:plan publisher=true
65+
```
66+
67+
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.
68+
69+
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.
5470

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

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

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -181,12 +181,18 @@ def doctor
181181
existing_rules: api("rulesets?per_page=100"),
182182
environments: api("environments"),
183183
environment_changes: environment_changes,
184-
trusted_publisher: {
185-
repository_owner: @repository.split("/").first,
186-
repository_name: @repository.split("/").last,
187-
workflow_filename: "release-publish.yaml",
188-
environment: @config.fetch("environment"),
189-
}
184+
trusted_publisher: trusted_publisher,
185+
}
186+
end
187+
188+
# Describe the RubyGems publisher for the reviewed release workflow.
189+
# @returns [Hash] Repository, workflow filename, and environment settings.
190+
def trusted_publisher
191+
{
192+
repository_owner: @repository.split("/").first,
193+
repository_name: @repository.split("/").last,
194+
workflow_filename: "release-publish.yaml",
195+
environment: @config.fetch("environment"),
190196
}
191197
end
192198

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require "json"
7+
require "rubygems/gemcutter_utilities"
8+
require "rubygems/user_interaction"
9+
require "uri"
10+
require_relative "project"
11+
12+
module Bake
13+
module Gem
14+
module GitHub
15+
# Registers the reviewed release workflow with RubyGems.org using its owner API.
16+
class TrustedPublisher
17+
include ::Gem::UserInteraction
18+
include ::Gem::GemcutterUtilities
19+
20+
# The RubyGems API scope for managing trusted publishers.
21+
SCOPE = :configure_trusted_publishers
22+
23+
# The provider type accepted by the RubyGems trusted publisher API.
24+
TYPE = "OIDC::TrustedPublisher::GitHubAction"
25+
26+
# Load the gem identity and reviewed publisher settings from the current project.
27+
# @parameter root [String] The repository root, which must also be the working directory.
28+
# @parameter options [Hash] Authentication options passed to {initialize}.
29+
# @returns [TrustedPublisher] The configured RubyGems client.
30+
def self.load(root, **options)
31+
project = Project.new(root)
32+
name = Helper.new(root).gemspec.name
33+
34+
new(name, project.trusted_publisher, **options)
35+
end
36+
37+
# Configure an existing gem's expected GitHub Actions publisher.
38+
# @parameter name [String] The gem name.
39+
# @parameter settings [Hash] Repository owner/name, workflow filename, and environment.
40+
# @parameter key [Symbol | Nil] An explicitly selected key in RubyGems credentials.
41+
# @parameter otp [String | Nil] An optional MFA code; RubyGems can prompt when required.
42+
def initialize(name, settings, key: nil, otp: nil)
43+
@path = "api/v1/gems/#{URI.encode_www_form_component(name)}/trusted_publishers"
44+
@settings = settings.transform_keys(&:to_s)
45+
@options = {key: key, otp: otp}
46+
@host = "https://rubygems.org"
47+
@api_key = nil
48+
end
49+
50+
# @attribute [Hash] Options used by RubyGems authentication and MFA support.
51+
attr_reader :options
52+
53+
# Read the matching publisher without changing publisher configuration.
54+
# @returns [Hash] Whether the publisher is configured and its existing API record, if any.
55+
# @raises [RuntimeError] If RubyGems cannot list the gem's publishers.
56+
def status
57+
sign_in(scope: SCOPE) unless api_key
58+
publishers = request(:get, expected: "200")
59+
raise "Invalid RubyGems trusted publishers response." unless publishers.is_a?(Array)
60+
publisher = publishers.find{|record| matching?(record)}
61+
62+
return {configured: !publisher.nil?, publisher: publisher}
63+
end
64+
65+
# Register a missing publisher, preserving all existing publisher registrations.
66+
# @returns [Hash] Whether registration was created and the matching API record.
67+
# @raises [RuntimeError] If RubyGems rejects registration or returns different settings.
68+
def register
69+
current = status
70+
return {created: false, publisher: current.fetch(:publisher)} if current.fetch(:configured)
71+
72+
publisher = request(:post, expected: "201", payload: {trusted_publisher_type: TYPE, trusted_publisher: @settings})
73+
raise "RubyGems returned unexpected trusted publisher settings." unless matching?(publisher)
74+
75+
return {created: true, publisher: publisher}
76+
end
77+
78+
# Use the session key or an explicitly supplied credential, leaving the default push key alone.
79+
# @returns [String | Nil] The API key used for this session.
80+
def api_key
81+
@api_key || ENV["GEM_HOST_API_KEY"] || (options[:key] && verify_api_key(options[:key]))
82+
end
83+
84+
# Keep newly issued credentials in memory rather than changing the user's credentials file.
85+
# @parameter host [String] The authenticated RubyGems host.
86+
# @parameter key [String] The newly issued API key.
87+
def set_api_key(host, key)
88+
@api_key = key
89+
end
90+
91+
private
92+
93+
def get_mfa_params(profile)
94+
super.merge(expires_at: (Time.now.utc + 900).strftime("%Y-%m-%d %H:%M:%S UTC"))
95+
end
96+
97+
def matching?(record)
98+
return false unless record.fetch("trusted_publisher_type") == TYPE
99+
settings = record.fetch("trusted_publisher")
100+
101+
# Generated publishing workflows run in the configured repository, not a reusable workflow repository:
102+
@settings.all?{|key, value| settings[key] == value} &&
103+
settings["workflow_repository_owner"].nil? && settings["workflow_repository_name"].nil?
104+
end
105+
106+
def request(method, expected:, payload: nil)
107+
response = rubygems_api_request(method, @path, scope: SCOPE) do |request|
108+
request["Authorization"] = api_key
109+
request["Accept"] = "application/json"
110+
if payload
111+
request["Content-Type"] = "application/json"
112+
request.body = JSON.generate(payload)
113+
end
114+
end
115+
unless response.code == expected
116+
raise "RubyGems trusted publisher #{method.to_s.upcase} failed (HTTP #{response.code}): #{clean_text(response.body)}"
117+
end
118+
119+
JSON.parse(response.body)
120+
end
121+
end
122+
end
123+
end
124+
end

‎releases.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Releases
22

3+
## Unreleased
4+
5+
- Register RubyGems trusted publishers from the gemspec and release configuration with `gem:github:setup:publisher`, reusing existing registrations and RubyGems authentication/MFA support.
6+
- Check publisher registration explicitly with `gem:github:setup:plan publisher=true`.
7+
38
## v0.5.0
49

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

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
require "bake/gem/github/repository_context"
77
require "bake/gem/github/project_client"
8+
require "bake/gem/github/trusted_publisher"
89

910
describe Bake::Gem::GitHub::Project do
1011
include Bake::Gem::GitHub::RepositoryContext
@@ -120,6 +121,7 @@
120121

121122
it "reports desired and observed settings without writing" do
122123
expect(Bake::Gem::GitHub::Project).to receive(:new).with(repository).and_return(project)
124+
expect(Bake::Gem::GitHub::TrustedPublisher).not.to receive(:load)
123125
result = Bake::Context.load(repository).call("gem:github:setup:plan")
124126

125127
expect(result[:desired_rules].keys).to be == %w[reviews checks history tags]
@@ -128,6 +130,55 @@
128130
expect(project.writes).to be == []
129131
end
130132

133+
["false", "true"].each do |enabled|
134+
it "checks RubyGems only when explicitly requested", unique: enabled do
135+
expect(Bake::Gem::GitHub::Project).to receive(:new).with(repository).and_return(project)
136+
if enabled == "true"
137+
publisher = Object.new
138+
expect(Bake::Gem::GitHub::TrustedPublisher).to receive(:load).with(repository, key: :publisher, otp: "012345").and_return(publisher)
139+
expect(publisher).to receive(:status).and_return({configured: true})
140+
else
141+
expect(Bake::Gem::GitHub::TrustedPublisher).not.to receive(:load)
142+
end
143+
result = Bake::Context.load(repository).call("gem:github:setup:plan", "publisher=#{enabled}", "key=publisher", "otp=012345")
144+
145+
expect(result[:trusted_publisher_status]).to be == (enabled == "true" ? {configured: true} : nil)
146+
expect(project.writes).to be == []
147+
end
148+
end
149+
150+
it "registers a publisher through the setup task" do
151+
publisher = Object.new
152+
expect(Bake::Gem::GitHub::TrustedPublisher).to receive(:load).with(repository, key: :publisher, otp: "012345").and_return(publisher)
153+
expect(publisher).to receive(:register).and_return({created: true})
154+
result = Bake::Context.load(repository).call("gem:github:setup:publisher", "key=publisher", "otp=012345")
155+
156+
expect(result).to be == {created: true}
157+
expect(project.requests).to be == []
158+
end
159+
160+
it "loads the gem identity and publisher restrictions from the current project" do
161+
config_path = File.join(repository, "config/release.yaml")
162+
config = YAML.safe_load_file(config_path)
163+
config.merge!("repository" => "another-org/another-repository", "environment" => "production")
164+
File.write(config_path, YAML.dump(config))
165+
result = isolated_project(<<~'RUBY')
166+
require "bake/gem/github/trusted_publisher"
167+
class ConfiguredPublisher < Bake::Gem::GitHub::TrustedPublisher
168+
def self.new(name, settings, **options)
169+
{name: name, settings: settings, options: options}
170+
end
171+
end
172+
ConfiguredPublisher.load(Dir.pwd, key: :publisher, otp: "012345")
173+
RUBY
174+
175+
expect(result).to be == {
176+
name: "example",
177+
settings: {repository_owner: "another-org", repository_name: "another-repository", workflow_filename: "release-publish.yaml", environment: "production"},
178+
options: {key: :publisher, otp: "012345"},
179+
}
180+
end
181+
131182
it "creates missing managed rulesets while preserving unrelated rulesets" do
132183
rules << {"id" => 123, "name" => "Unrelated policy"}
133184

0 commit comments

Comments
 (0)