Skip to content

Commit e2c7d52

Browse files
committed
ci: add PyPI trusted-publishing release workflow
- Add `.github/workflows/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 publish` + OIDC Trusted Publishing, gated on the approval-protected `pypi` environment; `workflow_dispatch` runs build + smoke as a no-publish dry run - Split `make build` (canonical `uv build --no-sources`, used by the release) from `make build-dev` (build.py development build); repoint the `all` target, smoke suite, and CONTRIBUTING to `build-dev` - Add `SMOKE_TEST_DIST` to the nox smoke session so it installs a pre-built wheel/sdist instead of rebuilding, letting the release smoke-test the exact artifact it publishes
1 parent 012f399 commit e2c7d52

4 files changed

Lines changed: 211 additions & 22 deletions

File tree

.github/workflows/release.yml

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

CONTRIBUTING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ The API surface is intentionally limited. This keeps the library reliable, well-
2222
Set up the environment as described in [README.md](README.md#getting-started). Then, from the activated venv:
2323

2424
```shell
25-
# Build the package.
26-
make build
25+
# Build the package (development build).
26+
make build-dev
2727

2828
# Build the documentation.
2929
make docs

Makefile

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# Use of this source code is governed by a BSD-3-Clause license that can
44
# be found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause
55

6-
.PHONY: _maybe_patch_pyproject all api-list build check clean distclean distclean-all docs docs-clean docs-open env env-all env-docs env-highest-torch env-lowest-torch env-tutorial render-api-index set-auto-venv test test-cov test-fast test-highest-pytorch test-lowest-pytorch test-slow test-smoke test-tutorials version
6+
.PHONY: _maybe_patch_pyproject all api-list build build-dev check clean distclean distclean-all docs docs-clean docs-open env env-all env-docs env-highest-torch env-lowest-torch env-tutorial render-api-index set-auto-venv test test-cov test-fast test-highest-pytorch test-lowest-pytorch test-slow test-smoke test-tutorials version
77

88
SHELL := /bin/bash
99

@@ -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
#
@@ -174,7 +181,7 @@ endif
174181
# =============================================================================
175182

176183
# Default target - run full workflow
177-
all: clean distclean-all env-all check test-lowest-pytorch test-highest-pytorch build
184+
all: clean distclean-all env-all check test-lowest-pytorch test-highest-pytorch build-dev
178185

179186
# =============================================================================
180187
# Environment Setup
@@ -210,8 +217,17 @@ env-all: _maybe_patch_pyproject
210217
# Build
211218
# =============================================================================
212219

213-
# Build package
220+
# Build the canonical, publishable distribution (wheel + sdist) via the uv build
221+
# frontend. `--no-sources` ignores [tool.uv.sources], so the artifact doesn't
222+
# depend on uv-specific index overrides — the recommended way to build for
223+
# publication. This is what the release workflow runs.
214224
build:
225+
@uv build --no-sources
226+
227+
# Build the development distribution with build.py (standard version; build.py
228+
# also supports a PEP 440 .dev version via --dev). Used by contributors and the
229+
# smoke tests.
230+
build-dev:
215231
@$(call use_env,VENV) && uv run --no-sync --active python $(SCRIPTS)/make/build.py
216232

217233
# =============================================================================
@@ -252,6 +268,9 @@ test-slow:
252268

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

ci/nox/noxfile.py

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -36,32 +36,54 @@
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 working directory (the project root, set just below).
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).absolute()
69+
if not dist_path.is_file():
70+
session.error(f"SMOKE_TEST_DIST does not point to a file: {dist_path}")
71+
session.log(f"Installing pre-built distribution: {dist_path}")
72+
session.install(str(dist_path))
73+
else:
74+
session.log(f"Building package with Python {session.python}")
75+
session.install("build")
76+
session.run("make", "build-dev", external=True)
77+
session.log("Installing built package")
78+
79+
# Find the built wheel
80+
wheels = list(Path("dist").glob("*.whl"))
81+
if not wheels:
82+
session.error(f"Build unsuccessful for Python {session.python}")
83+
session.error("No wheel found in dist/")
84+
latest_wheel = max(wheels, key=lambda p: p.stat().st_mtime)
85+
session.install(str(latest_wheel))
86+
session.log("Build Succeeded!")
6587

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

0 commit comments

Comments
 (0)