22import json
33import os
44import sys
5+ import typing
56from http import HTTPStatus
67from pathlib import Path
7- from typing import NoReturn
88from urllib .parse import urlparse
99
1010import id # pylint: disable=redefined-builtin
9090See https://docs.pypi.org/trusted-publishers/troubleshooting/ for more help.
9191"""
9292
93+ _REUSABLE_WORKFLOW_WARNING = """
94+ The claims in this token suggest that the calling workflow is a reusable workflow.
95+
96+ In particular, this action was initiated by:
97+
98+ {job_workflow_ref}
99+
100+ Whereas its parent workflow is:
101+
102+ {workflow_ref}
103+
104+ Reusable workflows are **not currently supported** by PyPI's Trusted Publishing
105+ functionality, and are subject to breakage. Users are **strongly encouraged**
106+ to avoid using reusable workflows for Trusted Publishing until support
107+ becomes official.
108+
109+ For more information, see:
110+
111+ * https://docs.pypi.org/trusted-publishers/troubleshooting/#reusable-workflows-on-github
112+ * https://github.com/pypa/gh-action-pypi-publish/issues/166
113+ """
114+
93115# Rendered if the package index's token response isn't valid JSON.
94116_SERVER_TOKEN_RESPONSE_MALFORMED_JSON = """
95117Token request failed: the index produced an unexpected
110132""" # noqa: S105; not a password
111133
112134
113- def die (msg : str ) -> NoReturn :
135+ def die (msg : str ) -> typing . NoReturn :
114136 with _GITHUB_STEP_SUMMARY .open ('a' , encoding = 'utf-8' ) as io :
115137 print (_ERROR_SUMMARY_MESSAGE .format (message = msg ), file = io )
116138
@@ -122,6 +144,14 @@ def die(msg: str) -> NoReturn:
122144 sys .exit (1 )
123145
124146
147+ def warn (msg : str ) -> None :
148+ with _GITHUB_STEP_SUMMARY .open ('a' , encoding = 'utf-8' ) as io :
149+ print (msg , file = io )
150+
151+ msg = msg .replace ('\n ' , '%0A' )
152+ print (f'::warning::Potential workflow misconfiguration: { msg } ' , file = sys .stderr )
153+
154+
125155def debug (msg : str ):
126156 print (f'::debug::{ msg .title ()} ' , file = sys .stderr )
127157
@@ -161,13 +191,15 @@ def assert_successful_audience_call(resp: requests.Response, domain: str):
161191 )
162192
163193
164- def render_claims (token : str ) -> str :
194+ def extract_claims (token : str ) -> dict [ str , typing . Any ] :
165195 _ , payload , _ = token .split ('.' , 2 )
166196
167197 # urlsafe_b64decode needs padding; JWT payloads don't contain any.
168198 payload += '=' * (4 - (len (payload ) % 4 ))
169- claims = json .loads (base64 .urlsafe_b64decode (payload ))
199+ return json .loads (base64 .urlsafe_b64decode (payload ))
200+
170201
202+ def render_claims (claims : dict [str , typing .Any ]) -> str :
171203 def _get (name : str ) -> str : # noqa: WPS430
172204 return claims .get (name , 'MISSING' )
173205
@@ -182,6 +214,21 @@ def _get(name: str) -> str: # noqa: WPS430
182214 )
183215
184216
217+ def warn_on_reusable_workflow (claims : dict [str , typing .Any ]) -> None :
218+ # A reusable workflow is identified by having different values
219+ # for its workflow_ref (the initiating workflow) and job_workflow_ref
220+ # (the reusable workflow).
221+ if claims .get ('workflow_ref' ) == claims .get ('job_workflow_ref' ):
222+ return
223+
224+ warn (
225+ _REUSABLE_WORKFLOW_WARNING .format (
226+ job_workflow_ref = claims .get ('job_workflow_ref' ),
227+ workflow_ref = claims .get ('workflow_ref' ),
228+ ),
229+ )
230+
231+
185232def event_is_third_party_pr () -> bool :
186233 # Non-`pull_request` events cannot be from third-party PRs.
187234 if os .getenv ('GITHUB_EVENT_NAME' ) != 'pull_request' :
@@ -223,12 +270,19 @@ def event_is_third_party_pr() -> bool:
223270 oidc_token = id .detect_credential (audience = oidc_audience )
224271except id .IdentityError as identity_error :
225272 cause_msg_tmpl = (
226- _TOKEN_RETRIEVAL_FAILED_FORK_PR_MESSAGE if event_is_third_party_pr ()
273+ _TOKEN_RETRIEVAL_FAILED_FORK_PR_MESSAGE
274+ if event_is_third_party_pr ()
227275 else _TOKEN_RETRIEVAL_FAILED_MESSAGE
228276 )
229277 for_cause_msg = cause_msg_tmpl .format (identity_error = identity_error )
230278 die (for_cause_msg )
231279
280+
281+ # Perform a non-fatal check to see if we're running on a reusable
282+ # workflow, and emit a warning if so.
283+ oidc_claims = extract_claims (oidc_token )
284+ warn_on_reusable_workflow (oidc_claims )
285+
232286# Now we can do the actual token exchange.
233287mint_token_resp = requests .post (
234288 token_exchange_url ,
@@ -255,7 +309,7 @@ def event_is_third_party_pr() -> bool:
255309 for error in mint_token_payload ['errors' ]
256310 )
257311
258- rendered_claims = render_claims (oidc_token )
312+ rendered_claims = render_claims (oidc_claims )
259313
260314 die (
261315 _SERVER_REFUSED_TOKEN_EXCHANGE_MESSAGE .format (
0 commit comments