Skip to content

Commit 71eacf0

Browse files
committed
Add support for reusable workflows in trusted publishing
This change addresses issue #4294 by adding optional fields to specify a different repository for the workflow source when using GitHub Actions reusable workflows. When a repository calls a reusable workflow from a different repository, the OIDC token's `job_workflow_ref` claim points to the reusable workflow's location, not the caller's workflow. Previously, RubyGems trusted publishing only supported workflows defined in the same repository as the caller. Security: Still validates caller repository against repository_owner/name, preventing unauthorized repositories from publishing via shared workflows Example configuration for a gem using a shared release workflow: - repository_owner: "my-org" (the gem's repo - repository_name: my-gem - workflow_filename: shared-release.yml - workflow_repository_owner: shared-org (the shared workflow's repo) - workflow_repository_name: shared-workflows
1 parent 3c60f6d commit 71eacf0

File tree

16 files changed

+306
-12
lines changed

16 files changed

+306
-12
lines changed

app/models/oidc/trusted_publisher/github_action.rb

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,38 @@ class OIDC::TrustedPublisher::GitHubAction < ApplicationRecord
1111
validates :repository_owner, :repository_name, :workflow_filename, :repository_owner_id,
1212
presence: true, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }
1313
validates :environment, allow_nil: true, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }
14+
validates :workflow_repository_owner, :workflow_repository_name,
15+
allow_nil: true, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }
1416

1517
validate :unique_publisher
1618
validate :workflow_filename_format
19+
validate :workflow_repository_fields_consistency
1720

1821
def self.for_claims(claims)
1922
repository = claims.fetch(:repository)
2023
repository_owner, repository_name = repository.split("/", 2)
21-
workflow_prefix = "#{repository}/.github/workflows/"
22-
workflow_ref = claims.fetch(:job_workflow_ref).delete_prefix(workflow_prefix)
23-
workflow_filename = workflow_ref.sub(/@[^@]+\z/, "")
24+
job_workflow_ref = claims.fetch(:job_workflow_ref)
25+
26+
match = job_workflow_ref.match(%r{\A([^/]+)/([^/]+)/\.github/workflows/([^@]+)@})
27+
raise ActiveRecord::RecordNotFound, "Invalid job_workflow_ref format" unless match
28+
29+
workflow_repo_owner, workflow_repo_name, workflow_filename = match.captures
2430

2531
required = {
2632
repository_owner:, repository_name:, workflow_filename:,
2733
repository_owner_id: claims.fetch(:repository_owner_id)
2834
}
2935

3036
base = where(required)
37+
38+
same_repo = workflow_repo_owner == repository_owner && workflow_repo_name == repository_name
39+
base = if same_repo
40+
base.where(workflow_repository_owner: workflow_repo_owner, workflow_repository_name: workflow_repo_name)
41+
.or(base.where(workflow_repository_owner: nil, workflow_repository_name: nil))
42+
else
43+
base.where(workflow_repository_owner: workflow_repo_owner, workflow_repository_name: workflow_repo_name)
44+
end
45+
3146
if (env = claims[:environment])
3247
base.where(environment: env).or(base.where(environment: nil)).order(environment: :asc) # NULLS LAST by default for asc
3348
else
@@ -36,13 +51,17 @@ def self.for_claims(claims)
3651
end
3752

3853
def self.permitted_attributes
39-
%i[repository_owner repository_name workflow_filename environment]
54+
%i[repository_owner repository_name workflow_filename environment
55+
workflow_repository_owner workflow_repository_name]
4056
end
4157

4258
def self.build_trusted_publisher(params)
43-
params = params.reverse_merge(repository_owner_id: nil, repository_name: nil, workflow_filename: nil, environment: nil)
59+
params = params.reverse_merge(repository_owner_id: nil, repository_name: nil, workflow_filename: nil, environment: nil,
60+
workflow_repository_owner: nil, workflow_repository_name: nil)
4461
params.delete(:repository_owner_id)
4562
params[:environment] = nil if params[:environment].blank?
63+
params[:workflow_repository_owner] = nil if params[:workflow_repository_owner].blank?
64+
params[:workflow_repository_name] = nil if params[:workflow_repository_name].blank?
4665
find_or_initialize_by(params)
4766
end
4867

@@ -55,7 +74,9 @@ def payload
5574
repository_name:,
5675
repository_owner_id:,
5776
workflow_filename:,
58-
environment:
77+
environment:,
78+
workflow_repository_owner:,
79+
workflow_repository_name:
5980
}
6081
end
6182

@@ -98,7 +119,7 @@ def job_workflow_ref_condition(ref)
98119
OIDC::AccessPolicy::Statement::Condition.new(
99120
operator: "string_equals",
100121
claim: "job_workflow_ref",
101-
value: "#{repository}/#{workflow_slug}@#{ref}"
122+
value: "#{workflow_repository}/#{workflow_slug}@#{ref}"
102123
)
103124
end
104125

@@ -127,7 +148,7 @@ def initialize(trusted_publisher)
127148
def verify(cert)
128149
ref = cert.openssl.find_extension("1.3.6.1.4.1.57264.1.14")&.value_der&.then { OpenSSL::ASN1.decode(it).value }
129150
Sigstore::Policy::Identity.new(
130-
identity: "https://github.com/#{@trusted_publisher.repository}/#{@trusted_publisher.workflow_slug}@#{ref}",
151+
identity: "https://github.com/#{@trusted_publisher.workflow_repository}/#{@trusted_publisher.workflow_slug}@#{ref}",
131152
issuer: OIDC::Provider::GITHUB_ACTIONS_ISSUER
132153
).verify(cert)
133154
end
@@ -145,6 +166,14 @@ def name
145166

146167
def repository = "#{repository_owner}/#{repository_name}"
147168

169+
def workflow_repository
170+
if workflow_repository_owner.present? && workflow_repository_name.present?
171+
"#{workflow_repository_owner}/#{workflow_repository_name}"
172+
else
173+
repository
174+
end
175+
end
176+
148177
def workflow_slug = ".github/workflows/#{workflow_filename}"
149178

150179
def owns_gem?(rubygem) = rubygem_trusted_publishers.exists?(rubygem: rubygem)
@@ -169,7 +198,9 @@ def unique_publisher
169198
repository_name: repository_name,
170199
repository_owner_id: repository_owner_id,
171200
workflow_filename: workflow_filename,
172-
environment: environment
201+
environment: environment,
202+
workflow_repository_owner: workflow_repository_owner,
203+
workflow_repository_name: workflow_repository_name
173204
)
174205

175206
errors.add(:base, "publisher already exists")
@@ -181,4 +212,13 @@ def workflow_filename_format
181212
errors.add(:workflow_filename, "must end with .yml or .yaml") unless /\.ya?ml\z/.match?(workflow_filename)
182213
errors.add(:workflow_filename, "must be a filename only, without directories") if workflow_filename.include?("/")
183214
end
215+
216+
def workflow_repository_fields_consistency
217+
owner_present = workflow_repository_owner.present?
218+
name_present = workflow_repository_name.present?
219+
220+
return if owner_present == name_present
221+
222+
errors.add(:base, "workflow_repository_owner and workflow_repository_name must both be set or both be blank")
223+
end
184224
end

app/views/components/oidc/trusted_publisher/github_action/form_component.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ def view_template
99
field trusted_publisher_form, :text_field, :repository_name, autocomplete: :off
1010
field trusted_publisher_form, :text_field, :workflow_filename, autocomplete: :off
1111
field trusted_publisher_form, :text_field, :environment, autocomplete: :off, optional: true
12+
field trusted_publisher_form, :text_field, :workflow_repository_owner, autocomplete: :off, optional: true
13+
field trusted_publisher_form, :text_field, :workflow_repository_name, autocomplete: :off, optional: true
1214
end
1315
end
1416

config/locales/de.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ de:
9494
api_key_permissions: API-Schlüsselberechtigungen
9595
oidc/trusted_publisher/github_action:
9696
repository_owner_id: GitHub Repository-Besitzer-ID
97+
workflow_repository_owner:
98+
workflow_repository_name:
9799
oidc/pending_trusted_publisher:
98100
rubygem_name: RubyGem-Name
99101
errors:
@@ -1042,6 +1044,8 @@ de:
10421044
repository_name_help_html:
10431045
workflow_filename_help_html:
10441046
environment_help_html:
1047+
workflow_repository_owner_help_html:
1048+
workflow_repository_name_help_html:
10451049
pending:
10461050
rubygem_name_help_html:
10471051
duration:

config/locales/en.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ en:
9191
api_key_permissions: API Key Permissions
9292
oidc/trusted_publisher/github_action:
9393
repository_owner_id: GitHub Repository Owner ID
94+
workflow_repository_owner: Workflow Repository Owner
95+
workflow_repository_name: Workflow Repository Name
9496
oidc/pending_trusted_publisher:
9597
rubygem_name: RubyGem name
9698
errors:
@@ -971,6 +973,12 @@ en:
971973
The name of the <a href="https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment">GitHub Actions environment</a> that the above workflow uses for publishing.<br>
972974
This should be configured under the repository's settings.<br>
973975
While not required, a dedicated publishing environment is strongly encouraged, especially if your repository has maintainers with commit access who shouldn't have RubyGems.org gem push access.
976+
workflow_repository_owner_help_html: |
977+
<strong>For <a href="https://docs.github.com/en/actions/sharing-automations/reusing-workflows">reusable workflows</a> only:</strong> The GitHub organization or username that owns the repository containing the reusable workflow file.<br>
978+
Leave blank if the workflow is defined in the same repository as above (the common case).
979+
workflow_repository_name_help_html: |
980+
<strong>For <a href="https://docs.github.com/en/actions/sharing-automations/reusing-workflows">reusable workflows</a> only:</strong> The name of the repository containing the reusable workflow file.<br>
981+
Leave blank if the workflow is defined in the same repository as above (the common case).
974982
pending:
975983
rubygem_name_help_html: "The gem (on RubyGems.org) that will be created when this publisher is used"
976984
duration:

config/locales/es.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ es:
9292
api_key_permissions:
9393
oidc/trusted_publisher/github_action:
9494
repository_owner_id:
95+
workflow_repository_owner:
96+
workflow_repository_name:
9597
oidc/pending_trusted_publisher:
9698
rubygem_name:
9799
errors:
@@ -1088,6 +1090,8 @@ es:
10881090
repository_name_help_html:
10891091
workflow_filename_help_html:
10901092
environment_help_html:
1093+
workflow_repository_owner_help_html:
1094+
workflow_repository_name_help_html:
10911095
pending:
10921096
rubygem_name_help_html:
10931097
duration:

config/locales/fr.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ fr:
9191
api_key_permissions:
9292
oidc/trusted_publisher/github_action:
9393
repository_owner_id:
94+
workflow_repository_owner:
95+
workflow_repository_name:
9496
oidc/pending_trusted_publisher:
9597
rubygem_name:
9698
errors:
@@ -999,6 +1001,8 @@ fr:
9991001
repository_name_help_html:
10001002
workflow_filename_help_html:
10011003
environment_help_html:
1004+
workflow_repository_owner_help_html:
1005+
workflow_repository_name_help_html:
10021006
pending:
10031007
rubygem_name_help_html:
10041008
duration:

config/locales/ja.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ ja:
8787
api_key_permissions: APIキーのパーミッション
8888
oidc/trusted_publisher/github_action:
8989
repository_owner_id: GitHubリポジトリの所有者ID
90+
workflow_repository_owner:
91+
workflow_repository_name:
9092
oidc/pending_trusted_publisher:
9193
rubygem_name: RubyGem名
9294
errors:
@@ -990,6 +992,8 @@ ja:
990992
これはリポジトリの設定で構成されていると良いでしょう。<br>
991993
必須ではありませんが、個別の公開環境は強く推奨されます。
992994
特にリポジトリに、コミットアクセスを持つがRubyGems.orgへのgemのプッシュアクセスを持つべきではないメンテナがいるときが該当します。
995+
workflow_repository_owner_help_html:
996+
workflow_repository_name_help_html:
993997
pending:
994998
rubygem_name_help_html: "(RubyGems.orgの)gemはこの発行元が使われたときに作られます"
995999
duration:

config/locales/nl.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ nl:
8686
api_key_permissions:
8787
oidc/trusted_publisher/github_action:
8888
repository_owner_id:
89+
workflow_repository_owner:
90+
workflow_repository_name:
8991
oidc/pending_trusted_publisher:
9092
rubygem_name:
9193
errors:
@@ -956,6 +958,8 @@ nl:
956958
repository_name_help_html:
957959
workflow_filename_help_html:
958960
environment_help_html:
961+
workflow_repository_owner_help_html:
962+
workflow_repository_name_help_html:
959963
pending:
960964
rubygem_name_help_html:
961965
duration:

config/locales/pt-BR.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ pt-BR:
9191
api_key_permissions:
9292
oidc/trusted_publisher/github_action:
9393
repository_owner_id:
94+
workflow_repository_owner:
95+
workflow_repository_name:
9496
oidc/pending_trusted_publisher:
9597
rubygem_name:
9698
errors:
@@ -978,6 +980,8 @@ pt-BR:
978980
repository_name_help_html:
979981
workflow_filename_help_html:
980982
environment_help_html:
983+
workflow_repository_owner_help_html:
984+
workflow_repository_name_help_html:
981985
pending:
982986
rubygem_name_help_html:
983987
duration:

config/locales/zh-CN.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ zh-CN:
8787
api_key_permissions:
8888
oidc/trusted_publisher/github_action:
8989
repository_owner_id:
90+
workflow_repository_owner:
91+
workflow_repository_name:
9092
oidc/pending_trusted_publisher:
9193
rubygem_name:
9294
errors:
@@ -970,6 +972,8 @@ zh-CN:
970972
repository_name_help_html:
971973
workflow_filename_help_html:
972974
environment_help_html:
975+
workflow_repository_owner_help_html:
976+
workflow_repository_name_help_html:
973977
pending:
974978
rubygem_name_help_html:
975979
duration:

0 commit comments

Comments
 (0)