Skip to content

Commit bad4fea

Browse files
committed
CI integration testing for uv publish
1 parent 1125e5a commit bad4fea

File tree

3 files changed

+235
-0
lines changed

3 files changed

+235
-0
lines changed

.github/workflows/ci.yml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -932,6 +932,61 @@ jobs:
932932
env:
933933
UV_PROJECT_ENVIRONMENT: "/home/runner/example"
934934

935+
integration-test-publish:
936+
timeout-minutes: 10
937+
needs: build-binary-linux
938+
name: "integration test | uv publish"
939+
runs-on: ubuntu-latest
940+
# Only the main repository is a trusted publisher
941+
if: github.repository == 'astral-sh/uv'
942+
environment: uv-test-publish
943+
env:
944+
# No dbus in GitHub Actions
945+
PYTHON_KEYRING_BACKEND: keyrings.alt.file.PlaintextKeyring
946+
steps:
947+
- uses: actions/checkout@v4
948+
with:
949+
fetch-depth: 0
950+
951+
# Only publish a new release if the
952+
- uses: tj-actions/changed-files@v45
953+
id: changed
954+
with:
955+
files_yaml: |
956+
code:
957+
- "crates/uv-publish/**/*"
958+
- "scripts/publish/**/*"
959+
960+
- uses: actions/setup-python@v5
961+
with:
962+
python-version: "3.12"
963+
964+
- name: "Download binary"
965+
uses: actions/download-artifact@v4
966+
with:
967+
name: uv-linux-${{ github.sha }}
968+
969+
- name: "Prepare binary"
970+
run: chmod +x ./uv
971+
972+
- name: "Add password to keyring"
973+
run: |
974+
# `keyrings.alt` contains the plaintext keyring
975+
./uv tool install --with keyrings.alt "keyring<25.4.0" # TODO(konsti): Remove upper bound once fix is released
976+
echo $UV_TEST_PUBLISH_KEYRING | keyring set https://test.pypi.org/legacy/?astral-test-keyring __token__
977+
env:
978+
UV_TEST_PUBLISH_KEYRING: ${{ secrets.UV_TEST_PUBLISH_KEYRING }}
979+
980+
- name: "Publish test packages"
981+
if: ${{ steps.changed.outputs.code_any_changed }}
982+
# `-p 3.12` prefers the python we just installed over the one locked in `.python_version`.
983+
run: ./uv run -p 3.12 scripts/publish/test_publish.py --uv ./uv all
984+
env:
985+
RUST_LOG: uv=debug,uv_publish=trace
986+
UV_TEST_PUBLISH_TOKEN: ${{ secrets.UV_TEST_PUBLISH_TOKEN }}
987+
UV_TEST_PUBLISH_PASSWORD: ${{ secrets.UV_TEST_PUBLISH_PASSWORD }}
988+
UV_TEST_PUBLISH_GITLAB_PAT: ${{ secrets.UV_TEST_PUBLISH_GITLAB_PAT }}
989+
935990
cache-test-ubuntu:
936991
timeout-minutes: 10
937992
needs: build-binary-linux

scripts/publish/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
astral-test-*

scripts/publish/test_publish.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
# /// script
2+
# requires-python = ">=3.12"
3+
# dependencies = [
4+
# "httpx>=0.27,<0.28",
5+
# "packaging>=24.1,<25",
6+
# ]
7+
# ///
8+
9+
"""
10+
Test `uv publish` by uploading a new version of astral-test-<test case> to testpypi,
11+
authenticating by one of various options.
12+
13+
# Setup
14+
15+
**astral-test-token**
16+
Set the `UV_TEST_PUBLISH_TOKEN` environment variables.
17+
18+
**astral-test-password**
19+
Set the `UV_TEST_PUBLISH_PASSWORD` environment variable.
20+
This project also uses token authentication since it's the only thing that PyPI
21+
supports, but they both CLI options.
22+
TODO(konsti): Add an index for testing that supports username/password.
23+
24+
**astral-test-keyring**
25+
```console
26+
uv pip install keyring
27+
keyring set https://test.pypi.org/legacy/?astral-test-keyring __token__
28+
```
29+
The query parameter a horrible hack stolen from
30+
https://github.com/pypa/twine/issues/565#issue-555219267
31+
to prevent the other projects from implicitly using the same credentials.
32+
"""
33+
34+
import os
35+
import re
36+
from argparse import ArgumentParser
37+
from pathlib import Path
38+
from shutil import rmtree
39+
from subprocess import check_call
40+
41+
import httpx
42+
from packaging.utils import parse_sdist_filename, parse_wheel_filename
43+
44+
cwd = Path(__file__).parent
45+
46+
project_urls = {
47+
"astral-test-token": "https://test.pypi.org/simple/astral-test-token/",
48+
"astral-test-password": "https://test.pypi.org/simple/astral-test-password/",
49+
"astral-test-keyring": "https://test.pypi.org/simple/astral-test-keyring/",
50+
"astral-test-gitlab-pat": "https://gitlab.com/api/v4/projects/61853105/packages/pypi/simple/astral-test-gitlab-pat",
51+
}
52+
53+
54+
def get_new_version(project_name: str) -> str:
55+
"""Return the next free path version on pypi"""
56+
data = httpx.get(project_urls[project_name]).text
57+
versions = set()
58+
for filename in list(m.group(1) for m in re.finditer(">([^<]+)</a>", data)):
59+
if filename.endswith(".whl"):
60+
[_name, version, _build, _tags] = parse_wheel_filename(filename)
61+
else:
62+
[_name, version] = parse_sdist_filename(filename)
63+
versions.add(version)
64+
max_version = max(versions)
65+
66+
# Bump the path version to obtain an empty version
67+
release = list(max_version.release)
68+
release[-1] += 1
69+
return ".".join(str(i) for i in release)
70+
71+
72+
def create_project(project_name: str, uv: Path):
73+
if cwd.joinpath(project_name).exists():
74+
rmtree(cwd.joinpath(project_name))
75+
check_call([uv, "init", "--lib", project_name], cwd=cwd)
76+
pyproject_toml = cwd.joinpath(project_name).joinpath("pyproject.toml")
77+
78+
# Set to an unclaimed version
79+
toml = pyproject_toml.read_text()
80+
new_version = get_new_version(project_name)
81+
toml = re.sub('version = ".*"', f'version = "{new_version}"', toml)
82+
pyproject_toml.write_text(toml)
83+
84+
85+
def publish_project(project_name: str, uv: Path):
86+
# Create the project
87+
create_project(project_name, uv)
88+
89+
# Build the project
90+
check_call([uv, "build"], cwd=cwd.joinpath(project_name))
91+
92+
# Upload the project
93+
if project_name == "astral-test-token":
94+
env = os.environ.copy()
95+
env["UV_PUBLISH_TOKEN"] = os.environ["UV_TEST_PUBLISH_TOKEN"]
96+
check_call(
97+
[
98+
uv,
99+
"publish",
100+
"--publish-url",
101+
"https://test.pypi.org/legacy/",
102+
],
103+
cwd=cwd.joinpath(project_name),
104+
env=env,
105+
)
106+
elif project_name == "astral-test-password":
107+
env = os.environ.copy()
108+
env["UV_PUBLISH_PASSWORD"] = os.environ["UV_TEST_PUBLISH_PASSWORD"]
109+
check_call(
110+
[
111+
uv,
112+
"publish",
113+
"--publish-url",
114+
"https://test.pypi.org/legacy/",
115+
"--username",
116+
"__token__",
117+
],
118+
cwd=cwd.joinpath(project_name),
119+
env=env,
120+
)
121+
elif project_name == "astral-test-keyring":
122+
check_call(
123+
[
124+
uv,
125+
"publish",
126+
"--publish-url",
127+
"https://test.pypi.org/legacy/?astral-test-keyring",
128+
"--username",
129+
"__token__",
130+
"--keyring-provider",
131+
"subprocess",
132+
],
133+
cwd=cwd.joinpath(project_name),
134+
)
135+
elif project_name == "astral-test-gitlab-pat":
136+
env = os.environ.copy()
137+
env["UV_PUBLISH_PASSWORD"] = os.environ["UV_TEST_PUBLISH_GITLAB_PAT"]
138+
check_call(
139+
[
140+
uv,
141+
"publish",
142+
"--publish-url",
143+
"https://gitlab.com/api/v4/projects/61853105/packages/pypi",
144+
"--username",
145+
"astral-test-gitlab-pat",
146+
],
147+
cwd=cwd.joinpath(project_name),
148+
env=env,
149+
)
150+
else:
151+
raise ValueError(f"Unknown project name: {project_name}")
152+
153+
154+
def main():
155+
parser = ArgumentParser()
156+
parser.add_argument("projects", choices=list(project_urls) + ["all"], nargs="+")
157+
parser.add_argument("--uv")
158+
args = parser.parse_args()
159+
160+
if args.uv:
161+
# We change the working directory for the subprocess calls, so we have to
162+
# absolutize the path.
163+
uv = Path.cwd().joinpath(args.uv)
164+
else:
165+
check_call(["cargo", "build"])
166+
executable_suffix = ".exe" if os.name == "nt" else ""
167+
uv = cwd.parent.parent.joinpath(f"target/debug/uv{executable_suffix}")
168+
169+
if args.projects == ["all"]:
170+
projects = list(project_urls)
171+
else:
172+
projects = args.projects
173+
174+
for project_name in projects:
175+
publish_project(project_name, uv)
176+
177+
178+
if __name__ == "__main__":
179+
main()

0 commit comments

Comments
 (0)