diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..18dfe61 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 (+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-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 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 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" diff --git a/docs/cli.md b/docs/cli.md index f767248..cf4b27a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -13,8 +13,9 @@ ft [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` build stamp). Every command supports `--help`. +`ft --version` prints the installed version (torch-free; nightly wheels carry a +`+g` build stamp, tagged releases a bare version). Every command supports +`--help`. ## ft serve diff --git a/freetoken-kernel-cache/pyproject.toml b/freetoken-kernel-cache/pyproject.toml index 46c485f..ec37efb 100644 --- a/freetoken-kernel-cache/pyproject.toml +++ b/freetoken-kernel-cache/pyproject.toml @@ -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" } diff --git a/install.sh b/install.sh index 02821bf..a7df832 100755 --- a/install.sh +++ b/install.sh @@ -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. diff --git a/pyproject.toml b/pyproject.toml index fc96ed8..8bd653f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/python/freetoken/attention/fa.py b/python/freetoken/attention/fa.py index 81dd599..782ef04 100644 --- a/python/freetoken/attention/fa.py +++ b/python/freetoken/attention/fa.py @@ -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 diff --git a/scripts/build-release-wheels.sh b/scripts/build-release-wheels.sh index 0b13d2f..b23a71f 100755 --- a/scripts/build-release-wheels.sh +++ b/scripts/build-release-wheels.sh @@ -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 commit stamp -- dev builds only (default: 0) +# FREETOKEN_BUILD_RELEASE tagged-release mode: no stamp, and HEAD must be at the +# tag v exactly (default: 0) +# FREETOKEN_BUILD_DEV_STAMP stamp .dev instead of +g -- 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 +cu130 (no .g): build_backend.py appends + # .g 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 \ diff --git a/scripts/ci/manylinux-build.sh b/scripts/ci/manylinux-build.sh index ed5d5be..4d8ff91 100755 --- a/scripts/ci/manylinux-build.sh +++ b/scripts/ci/manylinux-build.sh @@ -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: /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 diff --git a/scripts/ci/retag-manylinux.py b/scripts/ci/retag-manylinux.py new file mode 100644 index 0000000..eaa7b80 --- /dev/null +++ b/scripts/ci/retag-manylinux.py @@ -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_`, 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())