Skip to content

Commit 15ff2ef

Browse files
fcollonvalpre-commit-ci[bot]blink1073
authored
Add support for PyPI trusted publisher and NPM provenance (#511)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Steven Silvester <[email protected]>
1 parent 8b3ef9d commit 15ff2ef

File tree

9 files changed

+142
-9
lines changed

9 files changed

+142
-9
lines changed

docs/source/get_started/making_release_from_releaser.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ already uses Jupyter Releaser.
1616

1717
- Add the token as `ADMIN_GITHUB_TOKEN` in the [repository secrets](https://docs.github.com/en/actions/reference/encrypted-secrets#creating-encrypted-secrets-for-a-repository) of your fork. The token must have `repo` and `workflow` scopes.
1818

19+
- Set up PyPI:
20+
21+
<details><summary>Using PyPI token (legacy way)</summary>
22+
1923
- If the repo generates PyPI release(s), create a scoped PyPI [token](https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/#saving-credentials-on-github). We recommend using a scoped token for security reasons.
2024

2125
- You can store the token as `PYPI_TOKEN` in your fork's `Secrets`.
@@ -34,8 +38,21 @@ already uses Jupyter Releaser.
3438
owner1/repo1/path/to/package2,token2
3539
```
3640
41+
</details>
42+
43+
<details><summary>Using PyPI trusted publisher (modern way)</summary>
44+
45+
- Set up your PyPI project by [adding a trusted publisher](https://docs.pypi.org/trusted-publishers/adding-a-publisher/)
46+
- if you use the example workflows, the _workflow name_ is `publish-release.yml` (or `full-release.yml`) and the
47+
_environment_ should be left blank.
48+
- Ensure the publish release job as `permissions`: `id-token : write` (see the [documentation](https://docs.pypi.org/trusted-publishers/using-a-publisher/))
49+
50+
</details>
51+
3752
- If the repo generates npm release(s), add access token for [npm](https://docs.npmjs.com/creating-and-viewing-access-tokens), saved as `NPM_TOKEN` in "Secrets".
3853
54+
> If you want to set _provenance_ on your package, you need to ensure the publish release job as `permissions`: `id-token : write` (see the [documentation](https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-github-actions)).
55+
3956
## Prep Release
4057
4158
- Go to the "Actions" tab in your fork of `jupyter_releaser`

docs/source/how_to_guides/convert_repo_from_releaser.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ A. Prep the `jupyter_releaser` fork:
2323
[repository secrets](https://docs.github.com/en/actions/reference/encrypted-secrets#creating-encrypted-secrets-for-a-repository).
2424
The token will need "public_repo", and "repo:status" permissions.
2525

26-
- [ ] Add access token for the [PyPI registry](https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/#saving-credentials-on-github) stored as `PYPI_TOKEN`.
26+
- [ ] Set up PyPI:
27+
28+
<details><summary>Using PyPI token (legacy way)</summary>
29+
30+
- Add access token for the [PyPI registry](https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/#saving-credentials-on-github) stored as `PYPI_TOKEN`.
2731
_Note_ For security reasons, it is recommended that you scope the access
2832
to a single repository, and use a variable called `PYPI_TOKEN_MAP` that is formatted as follows:
2933

@@ -39,8 +43,21 @@ A. Prep the `jupyter_releaser` fork:
3943
owner1/repo1/path/to/package2,token2
4044
```
4145

46+
</details>
47+
48+
<details><summary>Using PyPI trusted publisher (modern way)</summary>
49+
50+
- Set up your PyPI project by [adding a trusted publisher](https://docs.pypi.org/trusted-publishers/adding-a-publisher/)
51+
- if you use the example workflows, the _workflow name_ is `publish-release.yml` (or `full-release.yml`) and the
52+
_environment_ should be left blank.
53+
- Ensure the publish release job as `permissions`: `id-token : write` (see the [documentation](https://docs.pypi.org/trusted-publishers/using-a-publisher/))
54+
55+
</details>
56+
4257
- [ ] If needed, add access token for [npm](https://docs.npmjs.com/creating-and-viewing-access-tokens), saved as `NPM_TOKEN`.
4358

59+
> If you want to set _provenance_ on your package, you need to ensure the publish release job as `permissions`: `id-token : write` (see the [documentation](https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-github-actions)).
60+
4461
B. Prep target repository:
4562

4663
- [ ] Switch to Markdown Changelog

docs/source/how_to_guides/convert_repo_from_repo.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ See checklist below for details:
99
- Markdown changelog
1010
- Bump version configuration (if using Python), for example [hatch](https://hatch.pypa.io/latest/)
1111
- [Access token](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token) with access to target GitHub repo to run GitHub Actions.
12-
- Access token for the [PyPI registry](https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/#saving-credentials-on-github)
12+
- Set up:
13+
- \[_modern way_\] [Add a trusted publisher](https://docs.pypi.org/trusted-publishers/adding-a-publisher/) to your PyPI project
14+
- \[_legacy way_\] Access token for the [PyPI registry](https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/#saving-credentials-on-github)
1315
- If needed, access token for [npm](https://docs.npmjs.com/creating-and-viewing-access-tokens).
1416

1517
## Checklist for Adoption
@@ -22,10 +24,27 @@ See checklist below for details:
2224
access token to allow for branch protection rules, which block the pushing
2325
of commits when using the `GITHUB_TOKEN`, even when run from an admin user
2426
account.
25-
- [ ] Add access token for the [PyPI registry](https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/#saving-credentials-on-github) stored as `PYPI_TOKEN`.
27+
28+
- [ ] Set up PyPI:
29+
30+
<details><summary>Using PyPI token (legacy way)</summary>
31+
32+
- Add access token for the [PyPI registry](https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/#saving-credentials-on-github) stored as `PYPI_TOKEN`.
2633
_Note_ For security reasons, it is recommended that you scope the access
2734
to a single repository. Additionally, this token should belong to a
2835
machine account and not a user account.
36+
37+
</details>
38+
39+
<details><summary>Using PyPI trusted publisher (modern way)</summary>
40+
41+
- Set up your PyPI project by [adding a trusted publisher](https://docs.pypi.org/trusted-publishers/adding-a-publisher/)
42+
- if you use the example workflows, the _workflow name_ is `publish-release.yml` (or `full-release.yml`) and the
43+
_environment_ should be left blank.
44+
- Ensure the publish release job as `permissions`: `id-token : write` (see the [documentation](https://docs.pypi.org/trusted-publishers/using-a-publisher/))
45+
46+
</details>
47+
2948
- [ ] If needed, add access token for [npm](https://docs.npmjs.com/creating-and-viewing-access-tokens), saved as `NPM_TOKEN`. Again this should
3049
be created using a machine account that only has publish access.
3150
- [ ] Ensure that only trusted users with 2FA have admin access to the

example-workflows/full-release.yml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ on:
2525
jobs:
2626
full_release:
2727
runs-on: ubuntu-latest
28+
permissions:
29+
# This is useful if you want to use PyPI trusted publisher
30+
# and NPM provenance
31+
id-token: write
2832
steps:
2933
- uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
3034

@@ -51,9 +55,10 @@ jobs:
5155
- name: Finalize Release
5256
id: finalize-release
5357
env:
54-
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
55-
PYPI_TOKEN_MAP: ${{ secrets.PYPI_TOKEN_MAP }}
56-
TWINE_USERNAME: __token__
58+
# The following are needed if you use legacy PyPI set up
59+
# PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
60+
# PYPI_TOKEN_MAP: ${{ secrets.PYPI_TOKEN_MAP }}
61+
# TWINE_USERNAME: __token__
5762
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
5863
uses: jupyter-server/jupyter-releaser/.github/actions/finalize-release@v2
5964
with:

example-workflows/publish-release.yml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ on:
1515
jobs:
1616
publish_release:
1717
runs-on: ubuntu-latest
18+
permissions:
19+
# This is useful if you want to use PyPI trusted publisher
20+
# and NPM provenance
21+
id-token: write
1822
steps:
1923
- uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
2024

@@ -30,9 +34,10 @@ jobs:
3034
- name: Finalize Release
3135
id: finalize-release
3236
env:
33-
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
34-
PYPI_TOKEN_MAP: ${{ secrets.PYPI_TOKEN_MAP }}
35-
TWINE_USERNAME: __token__
37+
# The following are needed if you use legacy PyPI set up
38+
# PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
39+
# PYPI_TOKEN_MAP: ${{ secrets.PYPI_TOKEN_MAP }}
40+
# TWINE_USERNAME: __token__
3641
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
3742
uses: jupyter-server/jupyter-releaser/.github/actions/finalize-release@v2
3843
with:

jupyter_releaser/lib.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,11 @@ def publish_assets( # noqa
342342
if release_url and len(glob(f"{dist_dir}/*.whl")):
343343
twine_token = python.get_pypi_token(release_url, python_package_path)
344344

345+
if twine_token:
346+
# tell GitHub Actions to mask the token in any console logs,
347+
# to avoid leaking it
348+
util.run(f'echo "::add-mask::{twine_token}"')
349+
345350
if dry_run:
346351
# Start local pypi server with no auth, allowing overwrites,
347352
# in a temporary directory

jupyter_releaser/npm.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@ def handle_npm_config(npm_token):
135135
auth_entry = ""
136136

137137
text += f"\n{reg_entry}\n{auth_entry}"
138+
139+
if os.environ.get(util.GH_ID_TOKEN_TOKEN_VAR, ""):
140+
util.log("Turning on NPM provenance as id-token permission is set.")
141+
# See documentation https://docs.npmjs.com/generating-provenance-statements
142+
# Also https://github.blog/2023-04-19-introducing-npm-package-provenance/
143+
text += "\nprovenance=true"
144+
138145
text = text.strip() + "\n"
139146
util.log(f"writing npm config to {npmrc}")
140147
npmrc.write_text(text, encoding="utf-8")

jupyter_releaser/python.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,26 @@
22
# Copyright (c) Jupyter Development Team.
33
# Distributed under the terms of the Modified BSD License.
44
import atexit
5+
import json
56
import os
67
import os.path as osp
78
import re
89
import shlex
910
from glob import glob
11+
from io import BytesIO
1012
from pathlib import Path
1113
from subprocess import PIPE, CalledProcessError, Popen
1214
from tempfile import TemporaryDirectory
1315

16+
import requests
17+
1418
from jupyter_releaser import util
1519

1620
PYPROJECT = util.PYPROJECT
1721
SETUP_PY = util.SETUP_PY
1822

23+
PYPI_GH_API_TOKEN_URL = "https://pypi.org/_/oidc/github/mint-token" # noqa
24+
1925

2026
def build_dist(dist_dir, clean=True):
2127
"""Build the python dist files into a dist folder"""
@@ -95,11 +101,60 @@ def check_dist(
95101
util.run(cmd)
96102

97103

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+
98148
def get_pypi_token(release_url, python_package):
99149
"""Get the PyPI token
100150
101151
Note: Do not print the token in CI since it will not be sanitized
102152
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+
103158
twine_pwd = os.environ.get("PYPI_TOKEN", "")
104159
pypi_token_map = os.environ.get("PYPI_TOKEN_MAP", "").replace(r"\n", "\n")
105160
if pypi_token_map and release_url:

jupyter_releaser/util.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@
5757

5858
GIT_FETCH_CMD = "git fetch origin --filter=blob:none --quiet"
5959

60+
GH_ID_TOKEN_URL_VAR = "ACTIONS_ID_TOKEN_REQUEST_URL" # noqa
61+
GH_ID_TOKEN_TOKEN_VAR = "ACTIONS_ID_TOKEN_REQUEST_TOKEN" # noqa
62+
6063

6164
def run(cmd, **kwargs):
6265
"""Run a command as a subprocess and get the output as a string"""

0 commit comments

Comments
 (0)