|
2 | 2 | # Copyright (c) Jupyter Development Team.
|
3 | 3 | # Distributed under the terms of the Modified BSD License.
|
4 | 4 | import atexit
|
| 5 | +import json |
5 | 6 | import os
|
6 | 7 | import os.path as osp
|
7 | 8 | import re
|
8 | 9 | import shlex
|
9 | 10 | from glob import glob
|
| 11 | +from io import BytesIO |
10 | 12 | from pathlib import Path
|
11 | 13 | from subprocess import PIPE, CalledProcessError, Popen
|
12 | 14 | from tempfile import TemporaryDirectory
|
13 | 15 |
|
| 16 | +import requests |
| 17 | + |
14 | 18 | from jupyter_releaser import util
|
15 | 19 |
|
16 | 20 | PYPROJECT = util.PYPROJECT
|
17 | 21 | SETUP_PY = util.SETUP_PY
|
18 | 22 |
|
| 23 | +PYPI_GH_API_TOKEN_URL = "https://pypi.org/_/oidc/github/mint-token" # noqa |
| 24 | + |
19 | 25 |
|
20 | 26 | def build_dist(dist_dir, clean=True):
|
21 | 27 | """Build the python dist files into a dist folder"""
|
@@ -95,11 +101,60 @@ def check_dist(
|
95 | 101 | util.run(cmd)
|
96 | 102 |
|
97 | 103 |
|
| 104 | +def fetch_pypi_api_token() -> "str": |
| 105 | + """Fetch the PyPI API token for trusted publishers |
| 106 | +
|
| 107 | + This implements the manual steps described in https://docs.pypi.org/trusted-publishers/using-a-publisher/ |
| 108 | + as of June 19th, 2023. |
| 109 | +
|
| 110 | + It returns an empty string if it fails. |
| 111 | + """ |
| 112 | + util.log("Fetching PyPI OIDC token...") |
| 113 | + |
| 114 | + url = os.environ.get(util.GH_ID_TOKEN_URL_VAR, "") |
| 115 | + auth = os.environ.get(util.GH_ID_TOKEN_TOKEN_VAR, "") |
| 116 | + if not url or not auth: |
| 117 | + util.log( |
| 118 | + "Please verify that you have granted `id-token: write` permission to the publish workflow." |
| 119 | + ) |
| 120 | + return "" |
| 121 | + |
| 122 | + headers = {"Authorization": f"bearer {auth}", "Accept": "application/octet-stream"} |
| 123 | + |
| 124 | + sink = BytesIO() |
| 125 | + with requests.get(f"{url}&audience=pypi", headers=headers, stream=True, timeout=60) as r: |
| 126 | + r.raise_for_status() |
| 127 | + for chunk in r.iter_content(chunk_size=8192): |
| 128 | + sink.write(chunk) |
| 129 | + sink.seek(0) |
| 130 | + oidc_token = json.loads(sink.read().decode("utf-8")).get("value", "") |
| 131 | + |
| 132 | + if not oidc_token: |
| 133 | + util.log("Failed to fetch the OIDC token from PyPI.") |
| 134 | + return "" |
| 135 | + |
| 136 | + util.log("Fetching PyPI API token...") |
| 137 | + sink = BytesIO() |
| 138 | + with requests.post(PYPI_GH_API_TOKEN_URL, json={"token": oidc_token}, timeout=10) as r: |
| 139 | + r.raise_for_status() |
| 140 | + for chunk in r.iter_content(chunk_size=8192): |
| 141 | + sink.write(chunk) |
| 142 | + sink.seek(0) |
| 143 | + api_token = json.loads(sink.read().decode("utf-8")).get("token", "") |
| 144 | + |
| 145 | + return api_token |
| 146 | + |
| 147 | + |
98 | 148 | def get_pypi_token(release_url, python_package):
|
99 | 149 | """Get the PyPI token
|
100 | 150 |
|
101 | 151 | Note: Do not print the token in CI since it will not be sanitized
|
102 | 152 | if it comes from the PYPI_TOKEN_MAP"""
|
| 153 | + trusted_token = os.environ.get(util.GH_ID_TOKEN_TOKEN_VAR, "") |
| 154 | + |
| 155 | + if trusted_token: |
| 156 | + return fetch_pypi_api_token() |
| 157 | + |
103 | 158 | twine_pwd = os.environ.get("PYPI_TOKEN", "")
|
104 | 159 | pypi_token_map = os.environ.get("PYPI_TOKEN_MAP", "").replace(r"\n", "\n")
|
105 | 160 | if pypi_token_map and release_url:
|
|
0 commit comments