Skip to content

Commit 8f862af

Browse files
committed
ci: add PyPI trusted-publishing release workflow
- `release.yml`: on a `vX.Y.Z` tag, build the wheel + sdist, smoke-test them across the torch 2.8-2.11 matrix, then publish to PyPI via `uv` + OIDC Trusted Publishing, gated on the approval-protected `pypi` environment; `workflow_dispatch` runs build + smoke as a no-publish dry run - `noxfile.py`: add `SMOKE_TEST_DIST` to the smoke session to install a pre-built wheel/sdist (with `uv_no_install_project`) instead of rebuilding, so the published bytes are what get tested - `Makefile`: export `SMOKE_TEST_DIST` so `make test-smoke` passes it through to nox
1 parent 012f399 commit 8f862af

3 files changed

Lines changed: 195 additions & 17 deletions

File tree

.github/workflows/release.yml

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
name: Release
2+
3+
run-name: "Release ${{ github.ref_name }} · @${{ github.actor }}"
4+
5+
# Publishing to PyPI uses Trusted Publishing (OIDC) — no API token is stored.
6+
# A release manager pushes a `vMAJOR.MINOR.PATCH` tag; the `build` and
7+
# `smoke-test` jobs run automatically, then the `publish` job pauses on the
8+
# `pypi` GitHub environment until a *different* release manager approves it
9+
# (required reviewers + prevent self-review + wait timer are configured on the
10+
# environment).
11+
on:
12+
push:
13+
tags:
14+
# Strictly vMAJOR.MINOR.PATCH with numeric parts (e.g. v0.2.2). `[0-9]+`
15+
# excludes pre-releases (v1.2.3rc1) and other non-release tags so they
16+
# never start the release run. The `pypi` environment tag rule and
17+
# approval gate are secondary controls; the version guard below is the
18+
# final backstop.
19+
- "v[0-9]+.[0-9]+.[0-9]+"
20+
# Manual dry run: builds and smoke-tests the current ref but never publishes
21+
# (the publish job is gated to tag pushes). Trigger from the Actions tab
22+
# ("Release" -> "Run workflow") or `gh workflow run release.yml --ref <branch>`.
23+
workflow_dispatch:
24+
25+
# Least privilege by default; the publish job opts into `id-token: write`.
26+
permissions:
27+
contents: read
28+
29+
concurrency:
30+
# Serialize releases per tag and never cancel an in-flight publish.
31+
group: release-${{ github.ref }}
32+
cancel-in-progress: false
33+
34+
jobs:
35+
# ── Build the exact wheel + sdist that will be smoke-tested and published. ──
36+
build:
37+
name: Build distributions
38+
runs-on: ubuntu-latest
39+
timeout-minutes: 15
40+
steps:
41+
- name: Check out repository
42+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
43+
with:
44+
persist-credentials: false
45+
- name: Install uv
46+
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
47+
with:
48+
enable-cache: false
49+
- name: Verify the tag matches the package version
50+
# The published version comes from src/coreai_opt/_about.py, not the tag.
51+
# Fail early if they disagree so we never publish a mismatched/duplicate
52+
# version (PyPI uploads are immutable and cannot be overwritten).
53+
# Skipped on manual dry runs, where the ref is a branch, not a vX.Y.Z tag.
54+
if: github.event_name == 'push'
55+
run: |
56+
tag="${GITHUB_REF_NAME}"
57+
version="$(python3 -c "import runpy; print(runpy.run_path('src/coreai_opt/_about.py')['__version__'])")"
58+
echo "tag=${tag} package version=${version}"
59+
if [ "${tag}" != "v${version}" ]; then
60+
echo "::error::Tag ${tag} does not match package version v${version} (src/coreai_opt/_about.py). Bump __version__ to match the release tag."
61+
exit 1
62+
fi
63+
- name: Build wheel and sdist
64+
run: uv build
65+
- name: Upload distributions
66+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
67+
with:
68+
name: dist
69+
path: dist/
70+
if-no-files-found: error
71+
72+
# ── Smoke test the exact built wheel and sdist via `make test-smoke`. ──
73+
# Reuses the repo's smoke suite (ci/nox/noxfile.py → tests/test_smoke.py) but
74+
# points it at the pre-built artifact instead of rebuilding, so we test the
75+
# bytes we are about to publish. `make test-smoke` runs across every supported
76+
# Python version internally.
77+
smoke-test:
78+
name: Smoke test (${{ matrix.format }}, ${{ matrix.torch_group }})
79+
needs: build
80+
runs-on: ubuntu-latest
81+
timeout-minutes: 60
82+
env:
83+
INSTALL_PRECOMMIT: "false"
84+
strategy:
85+
fail-fast: false
86+
matrix:
87+
# Test both distribution formats against every supported torch version,
88+
# mirroring the PR CI smoke matrix (ci.yaml).
89+
format: [wheel, sdist]
90+
torch_group: [torch_2_8, torch_2_9, torch_2_10, torch_2_11]
91+
steps:
92+
- name: Check out repository
93+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
94+
with:
95+
persist-credentials: false
96+
- name: Install uv
97+
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
98+
with:
99+
enable-cache: false
100+
- name: Download distributions
101+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
102+
with:
103+
name: dist
104+
path: dist/
105+
- name: Smoke test the built ${{ matrix.format }} against ${{ matrix.torch_group }}
106+
run: |
107+
case "${{ matrix.format }}" in
108+
wheel) dist="$(echo dist/*.whl)" ;;
109+
sdist) dist="$(echo dist/*.tar.gz)" ;;
110+
esac
111+
test -f "${dist}" || { echo "::error::No ${{ matrix.format }} found in dist/"; exit 1; }
112+
echo "Smoke testing ${dist} against ${{ matrix.torch_group }}"
113+
make test-smoke SMOKE_TEST_DIST="${dist}" TORCH_GROUP="${{ matrix.torch_group }}"
114+
115+
# ── Publish to PyPI via Trusted Publishing. Only this job holds `id-token`. ──
116+
# It builds nothing and runs no project code: it just downloads the vetted
117+
# artifact and uploads it, keeping build/test dependencies out of the
118+
# OIDC-privileged job.
119+
publish:
120+
name: Publish to PyPI
121+
needs: [build, smoke-test]
122+
# Publish only on a tag push (never on a manual dry run) and never from forks.
123+
if: github.event_name == 'push' && github.repository == 'apple/coreai-optimization'
124+
runs-on: ubuntu-latest
125+
timeout-minutes: 15
126+
environment:
127+
name: pypi
128+
url: https://pypi.org/p/coreai-opt
129+
permissions:
130+
id-token: write # mint the OIDC token PyPI validates for Trusted Publishing
131+
contents: read
132+
steps:
133+
- name: Install uv
134+
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
135+
with:
136+
enable-cache: false
137+
- name: Download distributions
138+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
139+
with:
140+
name: dist
141+
path: dist/
142+
- name: Publish to PyPI
143+
# `always` requires Trusted Publishing (OIDC) — no fallback to tokens.
144+
run: uv publish --trusted-publishing always

Makefile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ LOWEST_TORCH_GROUP := torch_2_8
6464
TORCH_GROUP ?= $(HIGHEST_TORCH_GROUP)
6565
export TORCH_GROUP
6666

67+
# Optional path to a pre-built distribution (wheel or sdist) for `test-smoke` to
68+
# install instead of building from source; consumed by the nox smoke session via
69+
# $SMOKE_TEST_DIST (empty = build from source). Exported like TORCH_GROUP so it
70+
# reaches the nox subprocess.
71+
SMOKE_TEST_DIST ?=
72+
export SMOKE_TEST_DIST
73+
6774
# Documentation directory. Defaults to $(MAKEFILE_DIR)docs so the same recipe
6875
# works in both contexts:
6976
#
@@ -252,6 +259,9 @@ test-slow:
252259

253260
# Run smoke tests only (pass PYTEST_ARGS for custom flags, e.g., make test-smoke PYTEST_ARGS="--junitxml=results.xml").
254261
# Pass TORCH_GROUP to smoke test against a specific torch version (default: HIGHEST_TORCH_GROUP).
262+
# Pass SMOKE_TEST_DIST=<path to a .whl or .tar.gz> to smoke test a pre-built
263+
# distribution instead of building one from source (used by the release
264+
# workflow to test the exact artifact being published).
255265
test-smoke:
256266
@$(call use_env,VENV) && \
257267
echo "Running smoke tests..." && \

ci/nox/noxfile.py

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -36,32 +36,56 @@
3636

3737
TORCH_GROUP = os.environ.get("TORCH_GROUP")
3838

39+
# Optional path to a pre-built distribution (wheel or sdist) to smoke test
40+
# instead of building one from source. Set by the release workflow so the exact
41+
# artifact that will be uploaded to PyPI is what gets tested. Relative paths are
42+
# resolved against the repository root.
43+
SMOKE_TEST_DIST = os.environ.get("SMOKE_TEST_DIST")
44+
3945

4046
@session(
4147
python=get_supported_python_versions(),
4248
uv_extras=["coreai"],
4349
uv_groups=["test", TORCH_GROUP],
50+
# When testing a pre-built distribution, install only the project's
51+
# dependencies (not the project from source) so the distribution under test
52+
# is the sole coreai_opt on the path.
53+
uv_no_install_project=bool(SMOKE_TEST_DIST),
4454
)
4555
def smoke_tests(session: Session) -> None:
46-
"""Smoke test the package build and coreai_opt imports and basic functionality.
47-
48-
Builds the package using the nox session's Python version, installs it
49-
in a clean environment, and runs smoke tests to verify functionality.
56+
"""Smoke test the package and coreai_opt imports and basic functionality.
57+
58+
By default, builds the package using the nox session's Python version,
59+
installs it in a clean environment, and runs smoke tests to verify
60+
functionality. When the ``SMOKE_TEST_DIST`` environment variable points to a
61+
pre-built wheel or sdist, that distribution is installed and tested instead
62+
of building one — used by the release workflow to smoke test the exact
63+
artifact that will be published to PyPI.
5064
"""
5165
change_dir_to_project_root(session)
52-
session.log(f"Building package with Python {session.python}")
53-
session.install("build")
54-
session.run("make", "build", external=True)
55-
session.log("Installing built package")
56-
57-
# Find the built wheel
58-
wheels = list(Path("dist").glob("*.whl"))
59-
if not wheels:
60-
session.error(f"Build unsuccessful for Python {session.python}")
61-
session.error("No wheel found in dist/")
62-
latest_wheel = max(wheels, key=lambda p: p.stat().st_mtime)
63-
session.install(str(latest_wheel))
64-
session.log("Build Succeeded!")
66+
67+
if SMOKE_TEST_DIST:
68+
dist_path = Path(SMOKE_TEST_DIST)
69+
if not dist_path.is_absolute():
70+
dist_path = REPO_ROOT / dist_path
71+
if not dist_path.is_file():
72+
session.error(f"SMOKE_TEST_DIST does not point to a file: {dist_path}")
73+
session.log(f"Installing pre-built distribution: {dist_path}")
74+
session.install(str(dist_path))
75+
else:
76+
session.log(f"Building package with Python {session.python}")
77+
session.install("build")
78+
session.run("make", "build", external=True)
79+
session.log("Installing built package")
80+
81+
# Find the built wheel
82+
wheels = list(Path("dist").glob("*.whl"))
83+
if not wheels:
84+
session.error(f"Build unsuccessful for Python {session.python}")
85+
session.error("No wheel found in dist/")
86+
latest_wheel = max(wheels, key=lambda p: p.stat().st_mtime)
87+
session.install(str(latest_wheel))
88+
session.log("Build Succeeded!")
6589

6690
# setuptools is needed by torch.utils.cpp_extension (used by PT2E quantization);
6791
# required on Python 3.12+ where distutils was removed from stdlib.

0 commit comments

Comments
 (0)