Skip to content

Commit 0d85464

Browse files
committed
action, twine-upload: add dry_run setting
Just for testing. Signed-off-by: William Woodruff <[email protected]> action, twine-upload: scaffolding Signed-off-by: William Woodruff <[email protected]> oidc-exchange: initial skeleton Signed-off-by: William Woodruff <[email protected]> oidc-exchange, twine-upload: tweakage Signed-off-by: William Woodruff <[email protected]> twine, oidc: move mask back into exchange Signed-off-by: William Woodruff <[email protected]> oidc-exchange: TestPyPI support Signed-off-by: William Woodruff <[email protected]> action: remove oidc_audience input Signed-off-by: William Woodruff <[email protected]> oidc-exchange: debugging Signed-off-by: William Woodruff <[email protected]> oidc-exchange: debugging Signed-off-by: William Woodruff <[email protected]> oidc-exchange: typo Signed-off-by: William Woodruff <[email protected]> oidc-exchange: audience negotiation Signed-off-by: William Woodruff <[email protected]> debug: dump JWT payload This is not sensitive, since we strip the signature. Signed-off-by: William Woodruff <[email protected]> oidc-exchange: typo Signed-off-by: William Woodruff <[email protected]> oidc-exchange: debugging Signed-off-by: William Woodruff <[email protected]> oidc-exchange: oopsie Signed-off-by: William Woodruff <[email protected]> oidc-exchange: undo debugging changes Signed-off-by: William Woodruff <[email protected]> oidc-exchange: switch to `id` for OIDC cred Signed-off-by: William Woodruff <[email protected]> oidc-exchange: better error messages/step summaries Signed-off-by: William Woodruff <[email protected]> remove `dry_run` Signed-off-by: William Woodruff <[email protected]> requirements: `pip-compile` Signed-off-by: William Woodruff <[email protected]> Bump cryptography from 38.0.4 to 39.0.1 in /requirements Bumps [cryptography](https://github.com/pyca/cryptography) from 38.0.4 to 39.0.1. - [Release notes](https://github.com/pyca/cryptography/releases) - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](pyca/cryptography@38.0.4...39.0.1) --- updated-dependencies: - dependency-name: cryptography dependency-type: indirect ... Signed-off-by: dependabot[bot] <[email protected]> ⇪ Bump isort to v5.12.0 The previous version had a Poetry packaging problem. This patch fixes that. 🎨 Warn about empty password/token action input Before this patch, the warning would say that the token was expected to start with `pypi-` but it may be unobvious. With this change, the end-users are warned when they're passing a completely empty password value. Fixes #25. 🎨 Convert action inputs to use kebab-case Up until now, the action input names followed the snake_case naming pattern that is well familiar to the pythonistas. But in GitHub actions, the de-facto standard is using kebab-case, which is what this patch achieves. This style helps make the keys in YAML better standardized and distinguishable from other identifiers. The old snake_case names remain functional for the time being and will not be removed until at least v3 release of this action. 🐛 Make kebab options fall back for snake_case The previous release didn't take into account the action defaults so the promised fallbacks for the old input names didn't work. This patch corrects that mistake. oidc-exchange: context manager Signed-off-by: William Woodruff <[email protected]> twine-upload: reflow Signed-off-by: William Woodruff <[email protected]> oidc-exchange: input normalization Signed-off-by: William Woodruff <[email protected]> oidc-exchange: reflow Signed-off-by: William Woodruff <[email protected]> runtime.in: document dependency Signed-off-by: William Woodruff <[email protected]> twine-upload: enquote Signed-off-by: William Woodruff <[email protected]> twine-upload: only do OIDC flow when user is token Signed-off-by: William Woodruff <[email protected]> oidc-exchange: reflow Signed-off-by: William Woodruff <[email protected]> oidc-exchange: reflow Signed-off-by: William Woodruff <[email protected]> oidc-exhcange: factor out audience call check Signed-off-by: William Woodruff <[email protected]> README: document OIDC publishing Signed-off-by: William Woodruff <[email protected]> oidc-exchange: reflow Signed-off-by: William Woodruff <[email protected]>
1 parent 7eb3b70 commit 0d85464

File tree

9 files changed

+327
-31
lines changed

9 files changed

+327
-31
lines changed

.github/workflows/self-smoke-test-action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ jobs:
9191
with:
9292
user: ${{ env.devpi-username }}
9393
password: ${{ env.devpi-password }}
94-
repository_url: >-
94+
repository-url: >-
9595
http://devpi:${{ env.devpi-port }}/${{ env.devpi-username }}/public/
9696
9797
...

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ repos:
99
- id: add-trailing-comma
1010

1111
- repo: https://github.com/PyCQA/isort.git
12-
rev: 5.11.4
12+
rev: 5.12.0
1313
hooks:
1414
- id: isort
1515
args:

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ WORKDIR /app
2525
COPY LICENSE.md .
2626
COPY twine-upload.sh .
2727
COPY print-hash.py .
28+
COPY oidc-exchange.py .
2829

2930
RUN chmod +x twine-upload.sh
3031
ENTRYPOINT ["/app/twine-upload.sh"]

README.md

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,46 @@ PyPI, which is recommended to restrict the access the action has.
6161
The secret used in `${{ secrets.PYPI_API_TOKEN }}` needs to be created on the
6262
settings page of your project on GitHub. See [Creating & using secrets].
6363

64+
### Publishing with OpenID Connect
65+
66+
**IMPORTANT**: This functionality is in beta, and will not work for you
67+
unless you're a member of the PyPI OIDC beta testers' group. For more
68+
information, see
69+
[warehouse#12965](https://github.com/pypi/warehouse/issues/12965).
70+
71+
This action supports PyPI's
72+
[OpenID Connect publishing](https://pypi.org/help/#openid-connect)
73+
implementation, which allows authentication to PyPI without a manually
74+
configured API token or username/password combination. To perform
75+
OIDC publishing with this action, your project's OIDC publisher must
76+
already be configured on PyPI.
77+
78+
To enter the OIDC flow, configure this action's job with the `id-token: write`
79+
permission and **without** an explicit username or password:
80+
81+
```yaml
82+
jobs:
83+
pypi-publish:
84+
name: upload release to PyPI
85+
runs-on: ubuntu-latest
86+
permissions:
87+
# IMPORTANT: this permission is mandatory for OIDC publishing
88+
id-token: write
89+
steps:
90+
# retrieve your distributions here
91+
92+
- name: Publish package distributions to PyPI
93+
uses: pypa/gh-action-pypi-publish@release/v1
94+
```
95+
96+
Other indices that support OIDC publishing can also be used, like TestPyPI:
97+
98+
```yaml
99+
- name: Publish package distributions to TestPyPI
100+
uses: pypa/gh-action-pypi-publish@release/v1
101+
with:
102+
repository-url: https://test.pypi.org/legacy/
103+
```
64104

65105
## Non-goals
66106

@@ -99,7 +139,7 @@ project's specific needs.
99139
For example, you could implement a parallel workflow that
100140
pushes every commit to TestPyPI or your own index server,
101141
like `devpi`. For this, you'd need to (1) specify a custom
102-
`repository_url` value and (2) generate a unique version
142+
`repository-url` value and (2) generate a unique version
103143
number for each upload so that they'd not create a conflict.
104144
The latter is possible if you use `setuptools_scm` package but
105145
you could also invent your own solution based on the distance
@@ -114,7 +154,7 @@ The action invocation in this case would look like:
114154
uses: pypa/gh-action-pypi-publish@release/v1
115155
with:
116156
password: ${{ secrets.TEST_PYPI_API_TOKEN }}
117-
repository_url: https://test.pypi.org/legacy/
157+
repository-url: https://test.pypi.org/legacy/
118158
```
119159

120160
### Customizing target package dists directory
@@ -128,7 +168,7 @@ would now look like:
128168
uses: pypa/gh-action-pypi-publish@release/v1
129169
with:
130170
password: ${{ secrets.PYPI_API_TOKEN }}
131-
packages_dir: custom-dir/
171+
packages-dir: custom-dir/
132172
```
133173

134174
### Disabling metadata verification
@@ -139,7 +179,7 @@ check with:
139179

140180
```yml
141181
with:
142-
verify_metadata: false
182+
verify-metadata: false
143183
```
144184

145185
### Tolerating release package file duplicates
@@ -149,12 +189,12 @@ may hit race conditions. For example, when publishing from multiple CIs
149189
or even having workflows with the same steps triggered within GitHub
150190
Actions CI/CD for different events concerning the same high-level act.
151191

152-
To facilitate this use-case, you may use `skip_existing` (disabled by
192+
To facilitate this use-case, you may use `skip-existing` (disabled by
153193
default) setting as follows:
154194

155195
```yml
156196
with:
157-
skip_existing: true
197+
skip-existing: true
158198
```
159199

160200
> **Pro tip**: try to avoid enabling this setting where possible. If you
@@ -177,7 +217,7 @@ It will show SHA256, MD5, BLAKE2-256 values of files to be uploaded.
177217

178218
```yml
179219
with:
180-
print_hash: true
220+
print-hash: true
181221
```
182222

183223
### Specifying a different username

action.yml

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,30 +9,75 @@ inputs:
99
password:
1010
description: Password for your PyPI user or an access token
1111
required: true
12-
repository_url:
12+
repository-url: # Canonical alias for `repository_url`
1313
description: The repository URL to use
1414
required: false
15-
packages_dir:
15+
repository_url: # DEPRECATED ALIAS; TODO: Remove in v3+
16+
description: >-
17+
[DEPRECATED]
18+
The repository URL to use
19+
deprecationMessage: >-
20+
The inputs have been normalized to use kebab-case.
21+
Use `repository-url` instead.
22+
required: false
23+
packages-dir: # Canonical alias for `packages_dir`
1624
description: The target directory for distribution
1725
required: false
26+
# default: dist # TODO: uncomment once alias removed
27+
packages_dir: # DEPRECATED ALIAS; TODO: Remove in v3+
28+
description: >-
29+
[DEPRECATED]
30+
The target directory for distribution
31+
deprecationMessage: >-
32+
The inputs have been normalized to use kebab-case.
33+
Use `packages-dir` instead.
34+
required: false
1835
default: dist
19-
verify_metadata:
36+
verify-metadata: # Canonical alias for `verify_metadata`
2037
description: Check metadata before uploading
2138
required: false
39+
# default: 'true' # TODO: uncomment once alias removed
40+
verify_metadata: # DEPRECATED ALIAS; TODO: Remove in v3+
41+
description: >-
42+
[DEPRECATED]
43+
Check metadata before uploading
44+
deprecationMessage: >-
45+
The inputs have been normalized to use kebab-case.
46+
Use `verify-metadata` instead.
47+
required: false
2248
default: 'true'
23-
skip_existing:
49+
skip-existing: # Canonical alias for `skip_existing`
2450
description: >-
2551
Do not fail if a Python package distribution
2652
exists in the target package index
2753
required: false
54+
# default: 'false' # TODO: uncomment once alias removed
55+
skip_existing: # DEPRECATED ALIAS; TODO: Remove in v3+
56+
description: >-
57+
[DEPRECATED]
58+
Do not fail if a Python package distribution
59+
exists in the target package index
60+
deprecationMessage: >-
61+
The inputs have been normalized to use kebab-case.
62+
Use `skip-existing` instead.
63+
required: false
2864
default: 'false'
2965
verbose:
3066
description: Show verbose output.
3167
required: false
3268
default: 'false'
33-
print_hash:
69+
print-hash: # Canonical alias for `print_hash`
3470
description: Show hash values of files to be uploaded
3571
required: false
72+
# default: 'false' # TODO: uncomment once alias removed
73+
print_hash: # DEPRECATED ALIAS; TODO: Remove in v3+
74+
description: >-
75+
[DEPRECATED]
76+
Show hash values of files to be uploaded
77+
deprecationMessage: >-
78+
The inputs have been normalized to use kebab-case.
79+
Use `print-hash` instead.
80+
required: false
3681
default: 'false'
3782
branding:
3883
color: yellow
@@ -43,9 +88,9 @@ runs:
4388
args:
4489
- ${{ inputs.user }}
4590
- ${{ inputs.password }}
46-
- ${{ inputs.repository_url }}
47-
- ${{ inputs.packages_dir }}
48-
- ${{ inputs.verify_metadata }}
49-
- ${{ inputs.skip_existing }}
91+
- ${{ inputs.repository-url }}
92+
- ${{ inputs.packages-dir }}
93+
- ${{ inputs.verify-metadata }}
94+
- ${{ inputs.skip-existing }}
5095
- ${{ inputs.verbose }}
51-
- ${{ inputs.print_hash }}
96+
- ${{ inputs.print-hash }}

oidc-exchange.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import os
2+
from pathlib import Path
3+
import sys
4+
from textwrap import dedent
5+
from typing import NoReturn
6+
from urllib.parse import urlparse
7+
8+
import id
9+
import requests
10+
11+
_GITHUB_STEP_SUMMARY = Path(os.getenv("GITHUB_STEP_SUMMARY"))
12+
13+
_TOKEN_RETRIEVAL_FAILED_MESSAGE = dedent(
14+
"""
15+
OIDC token retrieval failed: {identity_error}
16+
17+
This generally indicates a workflow configuration error, such as insufficient
18+
permissions. Make sure that your workflow has `id-token: write` configured
19+
at either the workflow or job level, e.g.:
20+
21+
```yaml
22+
permissions:
23+
id-token: write
24+
```
25+
"""
26+
)
27+
28+
29+
def die(msg: str) -> NoReturn:
30+
with _GITHUB_STEP_SUMMARY.open("a") as io:
31+
print(msg, file=io)
32+
33+
# NOTE: `msg` is Markdown formatted, so we emit only the header line to
34+
# avoid clogging the console log with a full Markdown formatted document.
35+
print(f"::error::OIDC exchange failure: {msg.splitlines()[0]}", file=sys.stderr)
36+
sys.exit(1)
37+
38+
39+
def debug(msg: str):
40+
print(f"::debug::{msg}", file=sys.stderr)
41+
42+
43+
def get_normalized_input(name: str) -> str | None:
44+
name = f"INPUT_{name.upper()}"
45+
if val := os.getenv(name):
46+
return val
47+
return os.getenv(name.replace("-", "_"))
48+
49+
50+
def assert_successful_audience_call(resp: requests.Response, domain: str):
51+
if resp.ok:
52+
return
53+
54+
match resp.status_code:
55+
case 403:
56+
# This index supports OIDC, but forbids the client from using
57+
# it (either because it's disabled, ratelimited, etc.)
58+
die(f"audience retrieval failed: repository at {domain} has OIDC disabled")
59+
case 404:
60+
# This index does not support OIDC.
61+
die(
62+
"audience retrieval failed: repository at "
63+
f"{domain} does not indicate OIDC support"
64+
)
65+
case other:
66+
# Unknown: the index may or may not support OIDC, but didn't respond with
67+
# something we expect. This can happen if the index is broken, in maintenance mode,
68+
# misconfigured, etc.
69+
die(
70+
"audience retrieval failed: repository at "
71+
f"{domain} responded with unexpected {other}"
72+
)
73+
74+
75+
repository_url = get_normalized_input("repository-url")
76+
if not repository_url:
77+
# Easy case: no explicit repository URL, which means we're using PyPI and we can just
78+
# hardcode the exchange endpoint and OIDC audience.
79+
token_exchange_url = "https://pypi.org/_/oidc/github/mint-token"
80+
oidc_audience = "pypi"
81+
else:
82+
repository_domain = urlparse(repository_url).netloc
83+
token_exchange_url = f"https://{repository_domain}/_/oidc/github/mint-token"
84+
85+
# Indices are expected to support `https://{domain}/_/oidc/audience`,
86+
# which tells OIDC exchange clients which audience to use.
87+
audience_url = f"https://{repository_domain}/_/oidc/audience"
88+
audience_resp = requests.get(audience_url)
89+
assert_successful_audience_call(audience_resp, repository_domain)
90+
91+
oidc_audience = audience_resp.json()["audience"]
92+
93+
debug(f"selected OIDC token exchange endpoint: {token_exchange_url}")
94+
95+
try:
96+
oidc_token = id.detect_credential(audience=oidc_audience)
97+
except id.IdentityError as identity_error:
98+
die(_TOKEN_RETRIEVAL_FAILED_MESSAGE.format(identity_error=identity_error))
99+
100+
# Now we can do the actual token exchange.
101+
mint_token_resp = requests.post(
102+
token_exchange_url,
103+
json={"token": oidc_token},
104+
)
105+
106+
if not mint_token_resp.ok:
107+
try:
108+
error_payload = mint_token_resp.json()
109+
except requests.JSONDecodeError:
110+
# Token exchange failure normally produces a JSON error response, but
111+
# we might have hit a server error instead.
112+
die(
113+
dedent(
114+
f"""
115+
Token request failed: the index produced an unexpected
116+
{mint_token_resp.status_code} response.
117+
118+
This strongly suggests a server configuration or downtime issue; wait
119+
a few minutes and try again.
120+
"""
121+
)
122+
)
123+
124+
reasons = "\n".join(
125+
f"* `{error['code']}`: {error['description']}"
126+
for error in error_payload["errors"]
127+
)
128+
129+
# NOTE: Can't `dedent(...)` here because `reasons` is newline-delimited.
130+
die(
131+
f"""
132+
Token request failed: the server refused the request for the following reasons:
133+
134+
{reasons}
135+
"""
136+
)
137+
138+
mint_token_payload = mint_token_resp.json()
139+
pypi_token = mint_token_payload.get("token")
140+
if pypi_token is None:
141+
die(
142+
dedent(
143+
"""
144+
Token response error: the index gave us an invalid response.
145+
146+
This strongly suggests a server configuration or downtime issue; wait
147+
a few minutes and try again.
148+
"""
149+
)
150+
)
151+
152+
# Mask the newly minted PyPI token, so that we don't accidentally leak it in logs.
153+
print(f"::add-mask::{pypi_token}", file=sys.stderr)
154+
155+
# This final print will be captured by the subshell in `twine-upload.sh`.
156+
print(pypi_token)

requirements/runtime.in

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
twine
22

3+
# NOTE: Used to detect an ambient OIDC credential for OIDC publishing.
4+
id ~= 1.0
5+
6+
# NOTE: This is pulled in transitively through `twine`, but we also declare
7+
# NOTE: it explicitly here because `oidc-exchange.py` uses it.
8+
# Ref: https://github.com/di/id
9+
requests
10+
311
# NOTE: `pkginfo` is a transitive dependency for us that is coming from Twine.
412
# NOTE: It is declared here only to avoid installing a broken combination of
513
# NOTE: the distribution packages. This should be removed once a fixed version

0 commit comments

Comments
 (0)