Skip to content

Commit 685a5e7

Browse files
alexclaude
andauthored
Switch to PyPI trusted publishing (#925)
Pushing a tag now automatically builds wheels and publishes to PyPI via OIDC trusted publishing, eliminating the need for API tokens. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 78e0aa3 commit 685a5e7

File tree

3 files changed

+86
-114
lines changed

3 files changed

+86
-114
lines changed

.github/workflows/pypi-publish.yml

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
name: Publish to PyPI
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
run_id:
7+
description: The run of wheel-builder to use for finding artifacts.
8+
required: true
9+
environment:
10+
description: Which PyPI environment to upload to
11+
required: true
12+
type: choice
13+
options: ["testpypi", "pypi"]
14+
workflow_run:
15+
workflows: ["Wheel Builder"]
16+
types: [completed]
17+
18+
permissions:
19+
contents: read
20+
21+
jobs:
22+
publish:
23+
runs-on: ubuntu-latest
24+
# We're not actually verifying that the triggering push event was for a
25+
# tag, because github doesn't expose enough information to do so.
26+
# wheel-builder.yml currently only has push events for tags.
27+
if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.event == 'push' && github.event.workflow_run.conclusion == 'success')
28+
permissions:
29+
id-token: write
30+
attestations: write
31+
steps:
32+
- run: echo "$EVENT_CONTEXT"
33+
env:
34+
EVENT_CONTEXT: ${{ toJson(github.event) }}
35+
36+
- run: |
37+
echo "PYPI_URL=https://upload.pypi.org/legacy/" >> $GITHUB_ENV
38+
if: github.event_name == 'workflow_run' || (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'pypi')
39+
- run: |
40+
echo "PYPI_URL=https://test.pypi.org/legacy/" >> $GITHUB_ENV
41+
if: github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'testpypi'
42+
43+
- uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
44+
with:
45+
path: tmpdist/
46+
run_id: ${{ github.event.inputs.run_id || github.event.workflow_run.id }}
47+
- run: mkdir dist/
48+
- run: |
49+
find tmpdist/ -type f -name 'PyNaCl*' -exec mv {} dist/ \;
50+
51+
- name: Publish package distributions to PyPI
52+
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
53+
with:
54+
repository-url: ${{ env.PYPI_URL }}
55+
skip-existing: true
56+
# Do not perform attestation for things for TestPyPI. This is
57+
# because there's nothing that would prevent a malicious PyPI from
58+
# serving a signed TestPyPI asset in place of a release intended for
59+
# PyPI.
60+
attestations: ${{ env.PYPI_URL == 'https://upload.pypi.org/legacy/' }}

.github/workflows/wheel-builder.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,26 @@ on:
1818
- setup.py
1919

2020
jobs:
21+
sdist:
22+
runs-on: ubuntu-latest
23+
name: sdist
24+
steps:
25+
- uses: actions/checkout@v6.0.1
26+
with:
27+
# The tag to build or the tag received by the tag event
28+
ref: ${{ github.event.inputs.version || github.ref }}
29+
persist-credentials: false
30+
- name: Setup python
31+
uses: actions/setup-python@v6
32+
with:
33+
python-version: "3.13"
34+
- run: pip install -U pip build
35+
- run: python -m build --sdist
36+
- uses: actions/upload-artifact@v6
37+
with:
38+
name: pynacl-sdist
39+
path: dist/PyNaCl*
40+
2141
manylinux:
2242
runs-on: ${{ matrix.MANYLINUX.RUNNER }}
2343
container:

release.py

Lines changed: 6 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -2,138 +2,30 @@
22
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
33
# for complete details.
44

5+
# /// script
6+
# dependencies = [
7+
# "click",
8+
# ]
9+
# ///
510

6-
import getpass
7-
import glob
8-
import io
9-
import json
10-
import os
1111
import subprocess
12-
import time
13-
import zipfile
1412

1513
import click
1614

17-
import requests
18-
1915

2016
def run(*args, **kwargs):
2117
print("[running] {}".format(list(args)))
2218
subprocess.check_call(list(args), **kwargs)
2319

2420

25-
def wait_for_build_complete_github_actions(session, token, run_url):
26-
while True:
27-
response = session.get(
28-
run_url,
29-
headers={
30-
"Content-Type": "application/json",
31-
"Authorization": "token {}".format(token),
32-
},
33-
)
34-
response.raise_for_status()
35-
if response.json()["conclusion"] is not None:
36-
break
37-
time.sleep(3)
38-
39-
40-
def download_artifacts_github_actions(session, token, run_url):
41-
response = session.get(
42-
run_url,
43-
headers={
44-
"Content-Type": "application/json",
45-
"Authorization": "token {}".format(token),
46-
},
47-
)
48-
response.raise_for_status()
49-
50-
response = session.get(
51-
response.json()["artifacts_url"],
52-
headers={
53-
"Content-Type": "application/json",
54-
"Authorization": "token {}".format(token),
55-
},
56-
)
57-
response.raise_for_status()
58-
paths = []
59-
for artifact in response.json()["artifacts"]:
60-
response = session.get(
61-
artifact["archive_download_url"],
62-
headers={
63-
"Content-Type": "application/json",
64-
"Authorization": "token {}".format(token),
65-
},
66-
)
67-
with zipfile.ZipFile(io.BytesIO(response.content)) as z:
68-
for name in z.namelist():
69-
if not name.endswith(".whl"):
70-
continue
71-
p = z.open(name)
72-
out_path = os.path.join(
73-
os.path.dirname(__file__),
74-
"dist",
75-
os.path.basename(name),
76-
)
77-
with open(out_path, "wb") as f:
78-
f.write(p.read())
79-
paths.append(out_path)
80-
return paths
81-
82-
83-
def build_github_actions_wheels(token, version):
84-
session = requests.Session()
85-
86-
response = session.post(
87-
"https://api.github.com/repos/pyca/pynacl/actions/workflows/"
88-
"wheel-builder.yml/dispatches",
89-
headers={
90-
"Content-Type": "application/json",
91-
"Accept": "application/vnd.github.v3+json",
92-
"Authorization": "token {}".format(token),
93-
},
94-
data=json.dumps({"ref": "master", "inputs": {"version": version}}),
95-
)
96-
response.raise_for_status()
97-
98-
# Give it a few seconds for the run to kick off.
99-
time.sleep(5)
100-
response = session.get(
101-
(
102-
"https://api.github.com/repos/pyca/pynacl/actions/workflows/"
103-
"wheel-builder.yml/runs?event=repository_dispatch"
104-
),
105-
headers={
106-
"Content-Type": "application/json",
107-
"Authorization": "token {}".format(token),
108-
},
109-
)
110-
response.raise_for_status()
111-
run_url = response.json()["workflow_runs"][0]["url"]
112-
wait_for_build_complete_github_actions(session, token, run_url)
113-
return download_artifacts_github_actions(session, token, run_url)
114-
115-
11621
@click.command()
11722
@click.argument("version")
11823
def release(version):
11924
"""
12025
``version`` should be a string like '0.4' or '1.0'.
12126
"""
122-
github_token = getpass.getpass("Github person access token: ")
123-
12427
run("git", "tag", "-s", version, "-m", "{} release".format(version))
125-
run("git", "push", "--tags")
126-
127-
run("python", "setup.py", "sdist")
128-
129-
sdist = glob.glob("dist/PyNaCl-{}*".format(version))
130-
131-
github_actions_wheel_paths = build_github_actions_wheels(
132-
github_token, version
133-
)
134-
135-
run("twine", "upload", *github_actions_wheel_paths)
136-
run("twine", "upload", "-s", *sdist)
28+
run("git", "push", "git@github.com:pyca/pynacl.git", version)
13729

13830

13931
if __name__ == "__main__":

0 commit comments

Comments
 (0)