build(release): pypi-ready wheels, metadata, and publish workflow (#6)

This commit is contained in:
Xiaoze Fan
2026-08-17 23:03:44 -07:00
committed by GitHub
9 changed files with 455 additions and 40 deletions
+196
View File
@@ -0,0 +1,196 @@
# Tagged-release wheels: build the cp310-cp313 runtime matrix + the kernel-cache
# wheel on the self-hosted node, then publish
# - runtime wheels (manylinux, bare version) -> PyPI, via twine + an API token
# - kernel-cache wheel (<version>+cu130) + runtime -> a DRAFT GitHub release on this repo
#
# INERT until a v* tag is pushed. The tag must point at a commit whose version.py
# matches (vX.Y.Z == __version__ X.Y.Z); scripts/build-release-wheels.sh enforces
# this in FREETOKEN_BUILD_RELEASE mode and refuses otherwise.
#
# This lane is separate from nightly-wheels.yml on purpose:
# - nightly ships +g<sha>-stamped, linux_x86_64-tagged cp312 wheels to the
# FreeToken-Web rolling `beta` release, which shipped Desktops resolve by
# asset name -- that channel's naming must not change.
# - release ships bare-versioned, manylinux-retagged cp310-cp313 wheels, which
# is what PyPI accepts.
#
# Security shape mirrors nightly-wheels.yml: fork code never reaches the
# self-hosted node (no pull_request trigger + repo guard), and credentials only
# exist on hosted runners. The upload tokens are ENVIRONMENT secrets on `pypi` /
# `testpypi`, so the environment's reviewers gate every use of them -- NOT the
# existing `release` environment, which holds the FreeToken-Web PAT and must
# stay off this lane.
#
# One-time owner setup:
# - GitHub environments `pypi` and `testpypi`, `pypi` WITH required reviewers:
# once this workflow is on main, any v* tag reaches publish-pypi, and that
# reviewer gate is the only thing in front of it.
# - Environment secret `PYPI_TOKEN` on `pypi` and `TEST_PYPI_TOKEN` on
# `testpypi`. Scope each token to the single project `freetoken`, never
# account-wide, and store them per-environment rather than repo-wide.
name: Release wheels
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
testpypi_rehearsal:
description: "Build .dev<run> wheels and publish them to TestPyPI (never touches PyPI)"
type: boolean
default: false
permissions:
contents: read
# Per-ref group, own namespace: sharing nightly-wheels' group would queue a release
# behind a 40-minute nightly build, and a SHARED release group would let a newer tag
# replace an earlier tag's still-PENDING run (GitHub keeps at most one pending run
# per group). Per-ref, back-to-back tags each keep their run; the single build
# runner serializes them anyway. No cancel-in-progress -- never kill a release mid-way.
concurrency:
group: release-wheels-${{ github.ref }}
jobs:
build:
# Guard against runs from forks of this repo; update on an org transfer.
# Mode is keyed on the EVENT, never on ref_type: a workflow_dispatch can be
# pointed at a tag ref, and that must stay a rehearsal, not become a release.
if: >-
github.repository == 'FlashML-org/FreeToken' &&
(github.event_name == 'push' || inputs.testpypi_rehearsal)
runs-on: [self-hosted, linux, engine-build]
timeout-minutes: 60
steps:
# The build container runs as root; an interrupted build can leave root-owned
# files that a plain checkout cannot delete. Wipe via a root container first.
- name: Clean workspace
run: |
docker run --rm -v "${{ github.workspace }}:/workspace" alpine:3 \
sh -c 'rm -rf /workspace/..?* /workspace/.[!.]* /workspace/*' || true
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
# Full history + tags: release mode verifies HEAD == tag == version.py
# via `git describe --exact-match`.
fetch-depth: 0
- name: Build wheels (manylinux container, cp310-cp313)
run: |
if [ "${{ github.event_name }}" = "push" ]; then
export FREETOKEN_BUILD_RELEASE=1
else
# Rehearsal: .dev<run_id> versions -- PEP 440-clean (no local
# segment) AND unique per run, so repeated rehearsals never collide
# with TestPyPI's no-reupload-ever rule. run_id, not run_number:
# run_number is stable across "Re-run all jobs", which would rebuild
# the identical filenames and let --skip-existing turn a re-run into
# a green no-op that leaves the previous attempt's bytes published.
export FREETOKEN_BUILD_DEV_STAMP="${{ github.run_id }}"
fi
export FT_PYTHON_MATRIX="cp310 cp311 cp312 cp313"
export FT_MANYLINUX_RETAG=1
scripts/ci/manylinux-build.sh
- name: Check the wheel set
run: |
ls -l dist/
rt="$(ls dist/freetoken-*manylinux*.whl 2>/dev/null | wc -l)"
kc="$(ls dist/freetoken_kernel_cache-*.whl 2>/dev/null | wc -l)"
[ "$rt" -eq 4 ] || { echo "expected 4 manylinux runtime wheels, got $rt" >&2; exit 1; }
[ "$kc" -eq 1 ] || { echo "expected 1 kernel-cache wheel, got $kc" >&2; exit 1; }
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: release-wheels
path: dist/*.whl
if-no-files-found: error
# Must outlast the `pypi` environment's review gate: GitHub lets a
# pending deployment sit for up to 30 days, and an expired artifact
# means redoing the whole 4-interpreter self-hosted build.
retention-days: 30
publish-testpypi:
needs: build
if: github.event_name == 'workflow_dispatch' && inputs.testpypi_rehearsal
runs-on: ubuntu-latest
timeout-minutes: 10
environment: testpypi
steps:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: release-wheels
path: dist
- name: Select the PyPI wheel set (runtime only, no local versions)
run: |
mkdir pypi-dist
cp dist/freetoken-*manylinux*.whl pypi-dist/
# The kernel-cache wheel stays on GitHub releases; a +local version
# here would be rejected by (Test)PyPI anyway -- fail early and loudly.
if ls pypi-dist/ | grep -qF '+'; then
echo "local-versioned wheel in the PyPI upload set:" >&2; ls pypi-dist/ >&2; exit 1
fi
pipx run twine check --strict pypi-dist/*
# --skip-existing keeps a partial rerun safe: (Test)PyPI never accepts the
# same filename twice, so without it a rerun after a mid-upload failure
# fails on the wheels that already landed.
- name: Upload to TestPyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.TEST_PYPI_TOKEN }}
TWINE_REPOSITORY_URL: https://test.pypi.org/legacy/
run: pipx run twine upload --verbose --skip-existing pypi-dist/*
publish-pypi:
needs: build
# Tag PUSH only -- a workflow_dispatch pointed at a tag ref must never reach PyPI.
if: github.event_name == 'push' && github.ref_type == 'tag'
runs-on: ubuntu-latest
timeout-minutes: 10
environment: pypi
steps:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: release-wheels
path: dist
- name: Select the PyPI wheel set (runtime only, no local versions)
run: |
mkdir pypi-dist
cp dist/freetoken-*manylinux*.whl pypi-dist/
if ls pypi-dist/ | grep -qF '+'; then
echo "local-versioned wheel in the PyPI upload set:" >&2; ls pypi-dist/ >&2; exit 1
fi
pipx run twine check --strict pypi-dist/*
# See the note on --skip-existing in publish-testpypi. It matters more here:
# a PyPI filename burned by a half-finished upload can never be reused.
- name: Upload to PyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
run: pipx run twine upload --verbose --skip-existing pypi-dist/*
# Upload-only draft release on THIS repo carrying every wheel, most importantly
# the kernel-cache one PyPI cannot host. Deliberately not scripts/publish-wheels.sh:
# its prune-then-upload semantics and stamp-pairing gate are for the rolling beta
# channel, and would be wrong against an immutable version tag.
github-release:
needs: build
if: github.event_name == 'push' && github.ref_type == 'tag'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: release-wheels
path: dist
- name: Create draft release with all wheels
env:
GH_TOKEN: ${{ github.token }}
run: |
# Idempotent for re-runs: create the draft, or refresh assets on the
# existing release if a previous run got that far.
gh release create "$GITHUB_REF_NAME" --draft --verify-tag \
--title "$GITHUB_REF_NAME" \
--notes "Draft -- edit notes, then publish." \
-R "$GITHUB_REPOSITORY" \
dist/*.whl \
|| gh release upload "$GITHUB_REF_NAME" dist/*.whl --clobber -R "$GITHUB_REPOSITORY"
+3 -2
View File
@@ -13,8 +13,9 @@ ft <command> [args]
| `ft checkpoint` | Convert an HF checkpoint to the FTW fast-load format |
| `ft bench bw` | Benchmark CPU vs PCIe bandwidth to calibrate the MoE backend |
`ft --version` prints the installed version (torch-free; release wheels
include the `+g<sha>` build stamp). Every command supports `--help`.
`ft --version` prints the installed version (torch-free; nightly wheels carry a
`+g<sha>` build stamp, tagged releases a bare version). Every command supports
`--help`.
## ft serve
+4 -3
View File
@@ -22,9 +22,10 @@ requires-python = ">=3.10"
license = "Apache-2.0"
dependencies = ["apache-tvm-ffi==0.1.13.post3"]
# cu130 wheels are not published to PyPI, and uv applies these to build-system.requires
# when this directory is the build root — without them an isolated build pulls PyPI's
# default-CUDA torch and stamps the wheel +cu126/+cu128. Mirrors ../pyproject.toml.
# Provenance pin: PyPI's torch 2.11.0 is itself the cu130 build, but this index serves
# ONLY cu130 wheels, so an isolated build can never resolve a different-CUDA torch and
# stamp the wheel +cu126/+cu128. uv applies these to build-system.requires when this
# directory is the build root. Mirrors ../pyproject.toml.
[tool.uv.sources]
torch = { index = "pytorch-cu130" }
+3 -2
View File
@@ -201,8 +201,9 @@ mkdir -p "$FT_HOME"
# re-install can't inherit a stale/mismatched torch (e.g. an old cu128 venv after a cu130 bump).
"$UV" venv "$VENV" --python "$PY_VERSION" --clear
# torch and sglang-kernel cu130 wheels are not on PyPI. `unsafe-best-match` is needed
# because the pytorch index also mirrors stale copies of common deps (e.g.
# PyPI's torch 2.11.0 and sglang-kernel 0.4.5 are the same cu130 builds these indexes
# serve; the explicit indexes pin provenance to the cu130 channels. `unsafe-best-match`
# is needed because the pytorch index also mirrors stale copies of common deps (e.g.
# packaging<=24.1) that would shadow PyPI under uv's first-index strategy; all indexes
# here are trusted. [tool.uv.sources] does not survive into a built wheel, so the
# indexes it names must be repeated below.
+17 -2
View File
@@ -10,11 +10,26 @@ build-backend = "setuptools.build_meta"
name = "freetoken"
# Single source of truth: python/freetoken/version.py (read via the attr directive below).
dynamic = ["version"]
description = "FreeToken inference runtime"
description = "Local MoE-offload LLM inference runtime with OpenAI- and Anthropic-compatible APIs"
readme = "README.md"
requires-python = ">=3.10"
license = "Apache-2.0"
license-files = ["LICENSE"]
authors = [{ name = "FlashML Team" }]
# No License:: classifier: setuptools>=77 rejects mixing one with the SPDX string above.
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Intended Audience :: Science/Research",
"Operating System :: POSIX :: Linux",
"Environment :: GPU :: NVIDIA CUDA",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
]
# Version ranges are floor=last-verified, ceiling=next-major (kept loose enough to
# resolve, tight enough to bar a silent breaking bump). These ranges are the contract;
# there is no lockfile.
@@ -42,7 +57,7 @@ dependencies = [
"torch>=2.11,<2.12",
"tqdm>=4.66,<5",
"transformers>=5.5,<6",
"triton==3.6.0",
"triton==3.6.0; platform_system == 'Linux'",
"uvicorn>=0.30,<1",
]
+1 -1
View File
@@ -174,7 +174,7 @@ def _fa_sgl_impl(
from sgl_kernel.flash_attn import flash_attn_with_kvcache
except ImportError as e:
raise ImportError(
"sgl_kernel.flash_attn is not found. Please install it with `pip install sgl-kernel`.\n"
"sgl_kernel.flash_attn is not found. Please install it with `pip install 'freetoken[sgl]'`.\n"
"If you're sure it's correctly installed, try `apt update && apt install libnuma1`."
) from e
+63 -14
View File
@@ -13,6 +13,14 @@
# FREETOKEN_BUILD_KEEP_TEMP keep setuptools/tvm build leftovers when true (default: 0)
# FREETOKEN_BUILD_STRIP strip debug symbols from the runtime wheel's .so (default: 1)
# FREETOKEN_BUILD_NO_STAMP skip the +g<sha> commit stamp -- dev builds only (default: 0)
# FREETOKEN_BUILD_RELEASE tagged-release mode: no stamp, and HEAD must be at the
# tag v<version.py> exactly (default: 0)
# FREETOKEN_BUILD_DEV_STAMP stamp .dev<N> instead of +g<sha> -- PEP 440-legal without
# a local segment, so TestPyPI rehearsals get a unique,
# uploadable version per run (default: unset)
# FREETOKEN_BUILD_SKIP_KERNEL_CACHE build only the runtime wheel -- for the 2nd..Nth
# interpreter of a matrix build; the kernel cache is
# py3-none and only needs building once (default: 0)
# FREETOKEN_KERNEL_CACHE_SPECS optional comma-separated subset of kernel spec names
set -euo pipefail
@@ -26,6 +34,9 @@ CLEAN="${FREETOKEN_BUILD_CLEAN:-1}"
KEEP_TEMP="${FREETOKEN_BUILD_KEEP_TEMP:-0}"
STRIP="${FREETOKEN_BUILD_STRIP:-1}"
NO_STAMP="${FREETOKEN_BUILD_NO_STAMP:-0}"
RELEASE="${FREETOKEN_BUILD_RELEASE:-0}"
DEV_STAMP="${FREETOKEN_BUILD_DEV_STAMP:-}"
SKIP_KERNEL_CACHE="${FREETOKEN_BUILD_SKIP_KERNEL_CACHE:-0}"
TRUE_VALUES=" 1 true yes on "
@@ -124,6 +135,30 @@ restore_version() {
fi
}
stamp_version() {
if [[ -n "$DEV_STAMP" ]] && { enabled "$RELEASE" || enabled "$NO_STAMP"; }; then
die "FREETOKEN_BUILD_DEV_STAMP cannot be combined with RELEASE or NO_STAMP modes"
fi
# Release mode: the runtime wheel must carry a bare PEP 440 version (PyPI rejects
# any +local segment), so no stamp -- provenance comes from the tag instead, which
# is verified to point at exactly this commit and this version.py. The kernel-cache
# wheel then comes out as <version>+cu130 (no .g<sha>): build_backend.py appends
# .g<sha> only when version.py already carries one.
if enabled "$RELEASE"; then
enabled "$NO_STAMP" \
&& die "FREETOKEN_BUILD_RELEASE and FREETOKEN_BUILD_NO_STAMP are mutually exclusive"
if [[ -n "$(git -C "$ROOT" status --porcelain)" ]]; then
die "working tree is not clean -- a release build must come from exactly the tagged commit."
fi
local version tag
version="$(sed -nE 's/^__version__ = "([^"+]+)".*$/\1/p' "$VERSION_FILE")"
[[ -n "$version" ]] || die "cannot read a version from $VERSION_FILE"
tag="$(git -C "$ROOT" describe --exact-match --tags HEAD 2>/dev/null)" \
|| die "FREETOKEN_BUILD_RELEASE: HEAD is not at a tag (expected tag v$version)."
[[ "$tag" == "v$version" ]] \
|| die "FREETOKEN_BUILD_RELEASE: HEAD tag is '$tag' but version.py says '$version' (expected tag v$version)."
say "release build: $version (tag $tag)"
return 0
fi
if enabled "$NO_STAMP"; then
warn "FREETOKEN_BUILD_NO_STAMP is set -- building UNSTAMPED dev wheels (do not release)"
return 0
@@ -134,7 +169,7 @@ stamp_version() {
# exactly that leftover (version.py is the only change, and it carries a stamp) and
# restore it instead of dying "not clean" at the operator.
if [[ "$(git -C "$ROOT" status --porcelain)" == " M python/freetoken/version.py" ]] \
&& grep -qE '^__version__ = "[0-9][^"]*\+g[0-9a-f]{7,}"' "$VERSION_FILE"; then
&& grep -qE '^__version__ = "[0-9][^"]*(\+g[0-9a-f]{7,}|\.dev[0-9]+)"' "$VERSION_FILE"; then
git -C "$ROOT" checkout --quiet -- "python/freetoken/version.py"
say "recovered a leftover version stamp from an interrupted build"
fi
@@ -145,11 +180,18 @@ stamp_version() {
if [[ -n "$(git -C "$ROOT" status --porcelain)" ]]; then
die "working tree is not clean -- a stamped wheel would lie about its commit. Commit/stash (and clean untracked files) first, or set FREETOKEN_BUILD_NO_STAMP=1 for an unstamped dev build."
fi
local sha
sha="$(git -C "$ROOT" rev-parse --short=9 HEAD)"
local suffix version
if [[ -n "$DEV_STAMP" ]]; then
[[ "$DEV_STAMP" =~ ^[0-9]+$ ]] || die "FREETOKEN_BUILD_DEV_STAMP must be a plain number (got '$DEV_STAMP')"
suffix=".dev${DEV_STAMP}"
else
suffix="+g$(git -C "$ROOT" rev-parse --short=9 HEAD)"
fi
version="$(sed -nE 's/^__version__ = "([^"+]+)".*$/\1/p' "$VERSION_FILE")"
[[ -n "$version" ]] || die "cannot read a version from $VERSION_FILE"
# In-place edit, not overwrite: anything in version.py beyond the version line survives.
sed -i -E "s/^(__version__ = \"[0-9][^\"+]*)\"/\1+g${sha}\"/" "$VERSION_FILE"
grep -qE "^__version__ = \"[0-9][^\"+]*\+g${sha}\"" "$VERSION_FILE" \
sed -i -E "s/^(__version__ = \"[0-9][^\"+]*)\"/\1${suffix}\"/" "$VERSION_FILE"
grep -qF "__version__ = \"${version}${suffix}\"" "$VERSION_FILE" \
|| die "failed to stamp $VERSION_FILE"
STAMPED=1
say "stamped version: $(sed -nE 's/^__version__ = "([^"]+)".*$/\1/p' "$VERSION_FILE")"
@@ -186,16 +228,23 @@ warn_arch_override
say "building freetoken runtime wheel"
uv "${BUILD_ARGS[@]}" .
# CLEAN wiped old wheels, so the sole freetoken-*.whl (NOT the kernel-cache) is what we just built.
rt_whl="$(find "$OUT_DIR" -maxdepth 1 -name 'freetoken-*.whl' \
! -name 'freetoken-kernel-cache-*.whl' -print | sort | tail -1)"
[ -n "$rt_whl" ] && strip_wheel "$rt_whl"
# Select the wheel THIS interpreter just built by its cp tag -- a matrix build runs
# this script once per interpreter with CLEAN=0, so several freetoken-*.whl coexist.
cptag="$("$PYTHON_BIN" -c 'import sys; print(f"cp{sys.version_info[0]}{sys.version_info[1]}")')"
rt_whl="$(find "$OUT_DIR" -maxdepth 1 -name "freetoken-*-${cptag}-*.whl" -printf '%T@ %p\n' \
| sort -n | tail -1 | cut -d' ' -f2-)"
[ -n "$rt_whl" ] || die "runtime wheel for $cptag not found in $OUT_DIR"
strip_wheel "$rt_whl"
say "building freetoken-kernel-cache wheel"
warn_arch_override
export FREETOKEN_KERNEL_CACHE_VERBOSE="${FREETOKEN_KERNEL_CACHE_VERBOSE:-1}"
export FREETOKEN_KERNEL_CACHE_BUILD_DIR="${FREETOKEN_KERNEL_CACHE_BUILD_DIR:-$ROOT/build/freetoken-kernel-cache}"
uv "${BUILD_ARGS[@]}" freetoken-kernel-cache
if enabled "$SKIP_KERNEL_CACHE"; then
say "skipping freetoken-kernel-cache wheel (FREETOKEN_BUILD_SKIP_KERNEL_CACHE)"
else
say "building freetoken-kernel-cache wheel"
warn_arch_override
export FREETOKEN_KERNEL_CACHE_VERBOSE="${FREETOKEN_KERNEL_CACHE_VERBOSE:-1}"
export FREETOKEN_KERNEL_CACHE_BUILD_DIR="${FREETOKEN_KERNEL_CACHE_BUILD_DIR:-$ROOT/build/freetoken-kernel-cache}"
uv "${BUILD_ARGS[@]}" freetoken-kernel-cache
fi
say "wheels written to $OUT_DIR"
find "$OUT_DIR" -maxdepth 1 -type f \
+61 -16
View File
@@ -15,7 +15,17 @@
# FT_CI_CACHE_DIR persistent cache dir on the host, holds the uv binary and
# uv's package cache across builds (default: ~/.cache/freetoken-ci)
# FT_OUT_DIR host dir that receives the wheels (default: <repo>/dist)
# FREETOKEN_BUILD_* passed through to scripts/build-release-wheels.sh
# FT_PYTHON_MATRIX space-separated cp tags to build the runtime wheel for
# (default: cp312 -- the nightly/Desktop channel is cp312-only;
# the release lane passes "cp310 cp311 cp312 cp313")
# FT_MANYLINUX_RETAG retag runtime wheels linux_x86_64 -> detected manylinux (default: 0).
# Release/PyPI lane only: shipped Desktops resolve the nightly
# release's assets by name and expect linux_x86_64.
# FREETOKEN_BUILD_NO_STAMP / _RELEASE / _DEV_STAMP / _STRIP and
# FREETOKEN_KERNEL_CACHE_* are forwarded into the container. Other
# FREETOKEN_BUILD_* vars are NOT: _CLEAN is set by this script per matrix
# iteration, and the rest (_KEEP_TEMP, _NO_ISOLATION, _OUT_DIR, ...) only
# make sense when driving build-release-wheels.sh directly.
set -euo pipefail
say() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
@@ -33,9 +43,13 @@ if [[ -z "${FT_IN_CONTAINER:-}" ]]; then
-e FT_HOST_UID="$(id -u)" \
-e FT_HOST_GID="$(id -g)" \
-e FREETOKEN_BUILD_NO_STAMP="${FREETOKEN_BUILD_NO_STAMP:-}" \
-e FREETOKEN_BUILD_RELEASE="${FREETOKEN_BUILD_RELEASE:-}" \
-e FREETOKEN_BUILD_DEV_STAMP="${FREETOKEN_BUILD_DEV_STAMP:-}" \
-e FREETOKEN_BUILD_STRIP="${FREETOKEN_BUILD_STRIP:-}" \
-e FREETOKEN_KERNEL_CACHE_SPECS="${FREETOKEN_KERNEL_CACHE_SPECS:-}" \
-e FREETOKEN_KERNEL_CACHE_VERBOSE="${FREETOKEN_KERNEL_CACHE_VERBOSE:-}" \
-e FT_PYTHON_MATRIX="${FT_PYTHON_MATRIX:-}" \
-e FT_MANYLINUX_RETAG="${FT_MANYLINUX_RETAG:-}" \
-v "$ROOT:/workspace" \
-v "$CACHE_DIR:/ci-cache" \
-v "$OUT_DIR:/ci-out" \
@@ -65,20 +79,51 @@ if [[ ! -x /ci-cache/bin/uv ]]; then
fi
export UV_CACHE_DIR=/ci-cache/uv
# Build venv is throwaway (recreated per build from the warm uv cache) so stale
# build deps can never linger; only the cache dir persists across builds.
VENV=/tmp/build-venv
PYBIN=/opt/python/cp312-cp312/bin/python
say "creating build venv"
uv venv --quiet --python "$PYBIN" "$VENV"
# torch must come from the cu130 index (PyPI's torch is a different CUDA variant
# and would tag the kernel-cache wheel wrong); everything else is plain PyPI.
uv pip install --quiet --python "$VENV/bin/python" \
--index-url https://download.pytorch.org/whl/cu130 "torch>=2.11,<2.12"
uv pip install --quiet --python "$VENV/bin/python" \
"setuptools>=77" wheel ninja "apache-tvm-ffi==0.1.13.post3"
MATRIX="${FT_PYTHON_MATRIX:-cp312}"
RETAG="${FT_MANYLINUX_RETAG:-0}"
export FREETOKEN_BUILD_PYTHON="$VENV/bin/python"
export FREETOKEN_BUILD_OUT_DIR=/ci-out
# No exec: the ownership trap above must still fire after the build returns.
bash scripts/build-release-wheels.sh
# One clean here instead of per-invocation: with several interpreters, each
# build-release-wheels.sh run would otherwise wipe the previous ABI's wheel.
rm -f /ci-out/freetoken-*.whl /ci-out/freetoken_kernel_cache-*.whl /ci-out/freetoken-kernel-cache-*.whl
export FREETOKEN_BUILD_CLEAN=0
# Build venvs are throwaway (recreated per build from the warm uv cache) so stale
# build deps can never linger; only the cache dir persists across builds.
first=1
for cptag in $MATRIX; do
PYBIN="/opt/python/${cptag}-${cptag}/bin/python"
[[ -x "$PYBIN" ]] || { echo "no such interpreter in the builder image: $PYBIN" >&2; exit 1; }
VENV="/tmp/build-venv-$cptag"
say "creating build venv ($cptag)"
uv venv --quiet --python "$PYBIN" "$VENV"
# Provenance pin: PyPI's torch 2.11.0 is itself the cu130 build, but this index
# serves ONLY cu130 wheels, so the resolve can never pick a different-CUDA torch
# and tag the kernel-cache wheel wrong; everything else is plain PyPI.
uv pip install --quiet --python "$VENV/bin/python" \
--index-url https://download.pytorch.org/whl/cu130 "torch>=2.11,<2.12"
uv pip install --quiet --python "$VENV/bin/python" \
"setuptools>=77" wheel ninja "apache-tvm-ffi==0.1.13.post3"
export FREETOKEN_BUILD_PYTHON="$VENV/bin/python"
# The kernel-cache wheel is py3-none: build it once, with the first interpreter.
export FREETOKEN_BUILD_SKIP_KERNEL_CACHE="$((1 - first))"
first=0
# No exec: the ownership trap above must still fire after the build returns.
bash scripts/build-release-wheels.sh
done
# Release/PyPI lane only (see the header note on FT_MANYLINUX_RETAG). The glob
# leaves the kernel-cache wheel alone: freetoken_* does not match freetoken-*.
case " 1 true yes on " in *" $(printf '%s' "$RETAG" | tr '[:upper:]' '[:lower:]') "*)
say "retagging runtime wheels to their detected manylinux policy"
uv pip install --quiet --python "$VENV/bin/python" "auditwheel==6.6.0"
found=0
for whl in /ci-out/freetoken-*linux_x86_64.whl; do
[[ -e "$whl" ]] || continue
"$VENV/bin/python" scripts/ci/retag-manylinux.py "$whl"
found=1
done
[[ "$found" == 1 ]] || { echo "FT_MANYLINUX_RETAG set but no linux_x86_64 runtime wheels in /ci-out" >&2; exit 1; }
;;
esac
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Retag `linux_x86_64` runtime wheels to their detected manylinux policy.
setuptools tags C-extension wheels `linux_<arch>`, which PyPI rejects at upload.
The CI build already runs inside the pytorch manylinux_2_28 container, so the
binaries meet the policy — only the tag is missing. This asks auditwheel which
policy the wheel's symbol versions actually satisfy and rewrites the tag.
Two deliberate choices:
* `sym_policy`, not the overall policy: the extensions intentionally leave
libtorch/libcudart as external NEEDED entries (provided by the installed
torch), and the overall policy grades any non-whitelisted external lib as
plain `linux`. `auditwheel repair` is equally unusable here — it would try
to graft libtorch into the wheel. Detection approach follows vLLM's
detect-manylinux-tag.py (Apache-2.0).
* Detected tag, not a hard-coded one, with a ceiling: a wheel built outside
the container (host glibc 2.3x) must fail loudly instead of shipping a tag
that lies about its glibc floor.
`wheel tags` rewrites filename + WHEEL metadata + RECORD consistently; renaming
the file alone would leave the wheel internally inconsistent.
Requires: auditwheel==6.6.0 (the analyze_wheel_abi signature is version
specific), wheel.
"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
from pathlib import Path
from auditwheel.error import NonPlatformWheelError, WheelToolsError
from auditwheel.wheel_abi import analyze_wheel_abi
from auditwheel.wheeltools import get_wheel_architecture, get_wheel_libc
from packaging.utils import InvalidWheelFilename
def detect_platform_tag(wheel: Path) -> str:
try:
arch = get_wheel_architecture(wheel.name)
except (WheelToolsError, NonPlatformWheelError, InvalidWheelFilename):
arch = None
try:
libc = get_wheel_libc(wheel.name)
except (WheelToolsError, InvalidWheelFilename):
libc = None
winfo = analyze_wheel_abi(
libc,
arch,
wheel,
frozenset(),
disable_isa_ext_check=False,
allow_graft=False,
)
# Deliberately not winfo.overall_policy: that folds in the external-library
# check, and libtorch/libcudart are not on any manylinux whitelist, so it
# always collapses to linux_x86_64. sym_policy (glibc symbols) and
# machine_policy (required ISA extensions) are the two components that do
# apply to us. min() by priority picks the stricter one -- the same
# combinator auditwheel itself uses -- so a future global -march= that
# raises the ISA floor cannot be silently retagged as broadly compatible.
return min(winfo.sym_policy, winfo.machine_policy).name
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("wheels", nargs="+", type=Path)
parser.add_argument(
"--max-glibc",
default="2.28",
help="highest glibc version the detected tag may require (default: 2.28)",
)
args = parser.parse_args()
m = re.fullmatch(r"(\d+)\.(\d+)", args.max_glibc)
if not m:
print(f"error: --max-glibc must look like '2.28' (got '{args.max_glibc}')", file=sys.stderr)
return 1
ceiling = (int(m[1]), int(m[2]))
for whl in args.wheels:
if not whl.is_file():
print(f"error: no such wheel: {whl}", file=sys.stderr)
return 1
tag = detect_platform_tag(whl)
m = re.fullmatch(r"manylinux_(\d+)_(\d+)_(\w+)", tag)
if not m or (int(m[1]), int(m[2])) > ceiling:
print(
f"error: {whl.name}: detected policy '{tag}' exceeds "
f"manylinux_{ceiling[0]}_{ceiling[1]} — this wheel was not "
"built in the manylinux container and must not be retagged",
file=sys.stderr,
)
return 2
subprocess.run(
[sys.executable, "-m", "wheel", "tags", "--platform-tag", tag, "--remove", str(whl)],
check=True,
stdout=subprocess.DEVNULL,
)
print(f"{whl.name} -> {tag}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())