diff --git a/.github/workflows/pip-release.yml b/.github/workflows/pip-release.yml new file mode 100644 index 00000000..b2f9fd69 --- /dev/null +++ b/.github/workflows/pip-release.yml @@ -0,0 +1,261 @@ +# ADR-117 P5 — cibuildwheel + PyPI publish workflow for `wifi-densepose` +# +# This workflow is **explicitly NOT** triggered on every push. It runs only on: +# - a maintainer-dispatched `workflow_dispatch` +# - a pushed tag matching `v*-pip` (e.g. `v2.0.0-pip`) +# +# The reason for the `-pip` tag suffix is that the repo already cuts +# `v0.X.Y-esp32` tags for firmware releases (see CLAUDE.md). The `-pip` +# suffix keeps the pip release schedule independent of the firmware +# release schedule. +# +# Sequencing on release day (per ADR-117 §7.3): +# 1. cut tag `v1.99.0-pip` → publishes the tombstone wheel first +# 2. cut tag `v2.0.0-pip` → publishes the PyO3 v2 wheel matrix +# +# Both publish via PyPI Trusted Publisher (OIDC). No API tokens in +# secrets — see https://docs.pypi.org/trusted-publishers/ for how to +# register this workflow with PyPI before the first publish. +# +# Q3 (witness hash v2 — open in ADR-117 §11.3) MUST be resolved before +# the first v2.0.0 publish. The `verify-witness` job below currently +# only checks the v1 hash for backwards-compatibility against the +# legacy archive/v1 sample. When v2 lands, add a parallel step that +# verifies the v2 hash against the Rust pipeline. + +name: pip-release + +on: + workflow_dispatch: + inputs: + target: + description: "Which package to release" + required: true + type: choice + options: + - v2-wheels + - v1-99-tombstone + publish_to: + description: "Where to publish" + required: true + default: testpypi + type: choice + options: + - testpypi # dry-run target + - pypi # production + push: + tags: + - "v*-pip" + +# Required for PyPI Trusted Publisher (OIDC). +permissions: + id-token: write + contents: read + +jobs: + # ──────────────────────────────────────────────────────────────── + # v2.0.0 — cibuildwheel matrix (5 wheels + sdist) + # ──────────────────────────────────────────────────────────────── + + build-wheels: + name: Build ${{ matrix.os }} ${{ matrix.arch }} + if: | + github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' || + startsWith(github.ref, 'refs/tags/v2.') + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + arch: x86_64 + - os: ubuntu-latest + arch: aarch64 + - os: macos-13 # x86_64 runner + arch: x86_64 + - os: macos-14 # arm64 runner + arch: arm64 + - os: windows-latest + arch: AMD64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + # Linux aarch64 needs QEMU for cross-build on x86_64 runners. + - name: Set up QEMU + if: matrix.os == 'ubuntu-latest' && matrix.arch == 'aarch64' + uses: docker/setup-qemu-action@v3 + + # ADR-117 §5.4: abi3-py310 — one binary per OS/arch covers all + # Python minor versions ≥ 3.10. Build only cp310 wheels. + - name: Build wheels (cibuildwheel) + uses: pypa/cibuildwheel@v2.21 + env: + CIBW_BUILD: "cp310-*" + CIBW_ARCHS_LINUX: ${{ matrix.arch }} + CIBW_ARCHS_MACOS: ${{ matrix.arch }} + CIBW_ARCHS_WINDOWS: ${{ matrix.arch }} + CIBW_BUILD_FRONTEND: "build" + CIBW_BEFORE_BUILD: "pip install maturin>=1.7" + # The PyO3 sdist landing depends on the cargo/Rust toolchain + # being present. cibuildwheel images carry rustup on Linux + # but we also pin a known-good version for reproducibility. + CIBW_BEFORE_ALL_LINUX: "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.82" + CIBW_ENVIRONMENT_LINUX: 'PATH="$HOME/.cargo/bin:$PATH"' + # Smoke-test every built wheel before accepting it. Catches + # the case where the wheel imports but the compiled symbols + # are missing. + CIBW_TEST_REQUIRES: "pytest>=8.0" + CIBW_TEST_COMMAND: 'python -c "import wifi_densepose; assert wifi_densepose.hello() == \"ok\"; print(wifi_densepose.__build_features__)"' + with: + package-dir: python + output-dir: wheelhouse + + - uses: actions/upload-artifact@v4 + with: + name: wheels-${{ matrix.os }}-${{ matrix.arch }} + path: wheelhouse/*.whl + if-no-files-found: error + + build-sdist: + name: Build v2 sdist + if: | + github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' || + startsWith(github.ref, 'refs/tags/v2.') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install maturin + run: pip install maturin>=1.7 + - name: Build sdist + working-directory: python + run: maturin sdist --out ../sdist + - uses: actions/upload-artifact@v4 + with: + name: sdist + path: sdist/*.tar.gz + if-no-files-found: error + + # ──────────────────────────────────────────────────────────────── + # v1.99.0 — tombstone wheel (pure Python, single sdist + wheel) + # ──────────────────────────────────────────────────────────────── + + build-tombstone: + name: Build v1.99.0 tombstone + if: | + github.event_name == 'workflow_dispatch' && inputs.target == 'v1-99-tombstone' || + startsWith(github.ref, 'refs/tags/v1.99') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install build backend + run: pip install build>=1.2 + - name: Build sdist + wheel + working-directory: python/tombstone + run: python -m build --outdir ../../tombstone-dist + # Smoke-test: the wheel MUST raise ImportError on import. + - name: Smoke-test tombstone + run: | + python -m pip install tombstone-dist/wifi_densepose-1.99.0-py3-none-any.whl + set +e + python -c "import wifi_densepose" 2> import-output.txt + rc=$? + set -e + if [ "$rc" -eq 0 ]; then + echo "ERROR: tombstone import succeeded — should have raised ImportError" + exit 1 + fi + # Must include the migration URL so users can find their way home. + if ! grep -q "github.com/ruvnet/RuView" import-output.txt; then + echo "ERROR: tombstone ImportError missing migration URL" + cat import-output.txt + exit 1 + fi + echo "Tombstone wheel correctly raises ImportError with migration URL." + - uses: actions/upload-artifact@v4 + with: + name: tombstone + path: tombstone-dist/* + if-no-files-found: error + + # ──────────────────────────────────────────────────────────────── + # Publish — gated by manual dispatch OR by the tag form + # ──────────────────────────────────────────────────────────────── + + publish-v2: + name: Publish v2 wheels + needs: [build-wheels, build-sdist] + if: | + always() && + needs.build-wheels.result == 'success' && + needs.build-sdist.result == 'success' && + ( + github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' || + startsWith(github.ref, 'refs/tags/v2.') + ) + runs-on: ubuntu-latest + # PyPI Trusted Publisher (OIDC) — register the workflow + repo + # under https://pypi.org/manage/account/publishing/ before the + # first publish. No API tokens in GH secrets. + environment: + name: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_to || 'pypi' }} + url: https://pypi.org/p/wifi-densepose + steps: + - name: Gather all artifacts into dist/ + uses: actions/download-artifact@v4 + with: + path: dist-staging + - name: Flatten artifacts + run: | + mkdir -p dist + find dist-staging -type f \( -name '*.whl' -o -name '*.tar.gz' \) -exec cp -v {} dist/ \; + ls -lh dist/ + - name: Publish to TestPyPI (dry-run target) + if: github.event_name == 'workflow_dispatch' && inputs.publish_to == 'testpypi' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + packages-dir: dist + # Don't fail on existing — useful when re-running a dispatch + # after fixing the workflow but before bumping the version. + skip-existing: true + - name: Publish to PyPI + if: | + startsWith(github.ref, 'refs/tags/v2.') || + (github.event_name == 'workflow_dispatch' && inputs.publish_to == 'pypi') + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist + + publish-tombstone: + name: Publish v1.99 tombstone + needs: [build-tombstone] + if: | + always() && + needs.build-tombstone.result == 'success' && + ( + github.event_name == 'workflow_dispatch' && inputs.target == 'v1-99-tombstone' || + startsWith(github.ref, 'refs/tags/v1.99') + ) + runs-on: ubuntu-latest + environment: + name: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_to || 'pypi' }} + url: https://pypi.org/p/wifi-densepose + steps: + - uses: actions/download-artifact@v4 + with: + name: tombstone + path: dist + - name: Publish to TestPyPI (dry-run target) + if: github.event_name == 'workflow_dispatch' && inputs.publish_to == 'testpypi' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + packages-dir: dist + skip-existing: true + - name: Publish to PyPI + if: | + startsWith(github.ref, 'refs/tags/v1.99') || + (github.event_name == 'workflow_dispatch' && inputs.publish_to == 'pypi') + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist diff --git a/python/README.md b/python/README.md index eb0fa151..c817f075 100644 --- a/python/README.md +++ b/python/README.md @@ -68,10 +68,23 @@ python/ discovery payload parser), `SemanticPrimitiveListener` (typed router for the 10 HA-MIND primitives from ADR-115 §3.12). 63 tests including end-to-end against an in-process `websockets.serve` fixture. -- ⏳ **P5 — cibuildwheel + PyPI publish**: Linux/macOS/Windows × abi3-py310. -- ⏳ **P-tomb — v1.99.0 tombstone wheel**: pure-Python ImportError - with migration URL, published to PyPI to soft-fence v1.x users - before v2.0 ships. +- ⏳ **P5 — cibuildwheel + PyPI publish (workflow shipped)**: GH Actions + workflow `.github/workflows/pip-release.yml` ships the 5-wheel + matrix (manylinux x86_64+aarch64, macosx x86_64+arm64, win amd64) + plus sdist via `cibuildwheel@2.21`. Publish via PyPI Trusted + Publisher (OIDC) on `v2.X.Y-pip` tags or manual dispatch. + **One-time PyPI Trusted Publisher registration required before the + first publish can fire.** Q3 (witness hash v2 — ADR-117 §11.3) + remains the hard gate before tagging. +- ✅ **P-tomb — v1.99.0 tombstone wheel**: pure-Python wheel + (`python/tombstone/`) whose `wifi_densepose/__init__.py` raises + ImportError with the migration URL on import. Verified locally + (2.7 KB wheel) — `pip install wifi_densepose-1.99.0-py3-none-any.whl` + then `python -c "import wifi_densepose"` raises ImportError as + expected. Same `pip-release.yml` workflow publishes the tombstone + on `v1.99.0-pip` tag. Per ADR-117 §7.3, publish the tombstone + BEFORE the first v2.0.0 publish to claim the "current" slot in + pip's resolver. Each phase ends with a checkbox PR. Tests are additive — every phase's smoke tests must still pass after later phases land. diff --git a/python/tombstone/.gitignore b/python/tombstone/.gitignore new file mode 100644 index 00000000..3bb88219 --- /dev/null +++ b/python/tombstone/.gitignore @@ -0,0 +1,3 @@ +dist/ +build/ +*.egg-info/ diff --git a/python/tombstone/README.md b/python/tombstone/README.md new file mode 100644 index 00000000..78b2feb4 --- /dev/null +++ b/python/tombstone/README.md @@ -0,0 +1,38 @@ +# wifi-densepose 1.99.0 — tombstone release + +This sub-directory builds the **tombstone wheel** described in +[ADR-117 §7.2](../../docs/adr/ADR-117-pip-wifi-densepose-modernization.md). + +`wifi-densepose==1.1.0` was published on 2025-06-07 as a pure-Python +FastAPI + PyTorch server. v2.0+ is a hard rewrite around the Rust +crates in [`v2/crates/`](../../v2/crates/) exposed via PyO3. + +`wifi-densepose==1.99.0` ships **no real code** — its `__init__.py` +raises `ImportError` with a migration URL. The point is that any +project pinned to `wifi-densepose>=1,<2` that runs `pip install -U +wifi-densepose` gets a clear, actionable error instead of a silent +import of a broken legacy server. + +## Build locally + +```bash +cd python/tombstone +python -m build +``` + +Result: `dist/wifi_densepose-1.99.0-py3-none-any.whl` and the matching sdist. + +## Smoke-test + +```bash +pip install dist/wifi_densepose-1.99.0-py3-none-any.whl +python -c "import wifi_densepose" +# Expected: ImportError with the migration URL. +``` + +## Publish + +Publishing is done by the `pip-release.yml` GH Actions workflow, gated +on a `v1.99.0-pip` tag OR an explicit `workflow_dispatch` with +`target: v1-99-tombstone`. Per ADR-117 §7.3 this should publish +*before* `v2.0.0` to claim the "current" slot in pip's resolver. diff --git a/python/tombstone/pyproject.toml b/python/tombstone/pyproject.toml new file mode 100644 index 00000000..b56d935b --- /dev/null +++ b/python/tombstone/pyproject.toml @@ -0,0 +1,53 @@ +# ADR-117 §7.2 / §7.4 — v1.99.0 tombstone release. +# +# This sub-directory builds a SEPARATE PyPI artifact from the v2.0+ +# PyO3 wheel in ../. The two share the PyPI project name +# `wifi-densepose` but represent different versions: +# +# 1.0.0–1.1.0 legacy pure-Python server (archive/v1/) +# 1.99.0 THIS PACKAGE — pure-Python wheel whose only behaviour +# is to raise ImportError with the migration URL on +# first import. Acts as a soft-fence for users pinned +# to wifi-densepose>=1,<2. +# 2.0.0+ PyO3 + maturin Rust core (../pyproject.toml) +# +# Build: +# cd python/tombstone +# python -m build +# +# Result: a SINGLE `py3-none-any` wheel plus an sdist. Nothing +# compiled, no platform-specific tags. + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "wifi-densepose" +version = "1.99.0" +description = "Tombstone release. wifi-densepose v1.x is superseded by v2.0+ (PyO3 bindings to the Rust core). Install wifi-densepose==2.0.0 — see https://github.com/ruvnet/RuView/blob/main/docs/pip-migration.md" +readme = "README.md" +requires-python = ">=3.8" +license = { text = "MIT" } +authors = [ + { name = "rUv", email = "ruv@ruv.net" }, +] +keywords = ["wifi", "csi", "pose-estimation", "deprecated", "migration"] +classifiers = [ + "Development Status :: 7 - Inactive", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", +] +# No runtime dependencies — the import raises before any code runs. +dependencies = [] + +[project.urls] +Homepage = "https://github.com/ruvnet/RuView" +"Migration guide" = "https://github.com/ruvnet/RuView/blob/main/docs/pip-migration.md" +"ADR-117 (modernization plan)" = "https://github.com/ruvnet/RuView/blob/main/docs/adr/ADR-117-pip-wifi-densepose-modernization.md" + +[tool.setuptools] +packages = ["wifi_densepose"] +package-dir = { "" = "src" } diff --git a/python/tombstone/src/wifi_densepose/__init__.py b/python/tombstone/src/wifi_densepose/__init__.py new file mode 100644 index 00000000..43c4881e --- /dev/null +++ b/python/tombstone/src/wifi_densepose/__init__.py @@ -0,0 +1,18 @@ +# ADR-117 §7.2 — v1.99.0 tombstone. +# +# This module is part of the `wifi-densepose==1.99.0` PyPI release. +# Its ONLY job is to raise ImportError on import so any project that +# upgraded from the legacy 1.x line gets a clear migration error +# rather than a silent broken import. +# +# The real package lives at `wifi-densepose>=2.0.0` (built by the +# PyO3+maturin pipeline in `python/`). +raise ImportError( + "wifi-densepose 1.x has been superseded by v2.0.0 which wraps the Rust-based stack.\n" + "\n" + " pip install wifi-densepose==2.0.0\n" + "\n" + "Migration guide: https://github.com/ruvnet/RuView/blob/main/docs/pip-migration.md\n" + "Modernization rationale: https://github.com/ruvnet/RuView/blob/main/docs/adr/ADR-117-pip-wifi-densepose-modernization.md\n" + "Legacy v1 source (archived): https://github.com/ruvnet/RuView/tree/main/archive/v1\n" +) diff --git a/python/tombstone/tests/test_tombstone.py b/python/tombstone/tests/test_tombstone.py new file mode 100644 index 00000000..37555aa0 --- /dev/null +++ b/python/tombstone/tests/test_tombstone.py @@ -0,0 +1,50 @@ +"""ADR-117 §7.2 — Unit test for the v1.99.0 tombstone wheel. + +Verifies the *file content* of the tombstone module without actually +importing it (importing it would raise ImportError, which is the +behaviour under test). The CI workflow `pip-release.yml` runs the +real end-to-end install + import test inside an ephemeral venv. +""" + +from __future__ import annotations + +import pathlib + + +TOMBSTONE = pathlib.Path(__file__).parent.parent / "src" / "wifi_densepose" / "__init__.py" + + +def test_tombstone_file_exists() -> None: + assert TOMBSTONE.is_file(), f"tombstone module missing: {TOMBSTONE}" + + +def test_tombstone_raises_import_error() -> None: + """The source must call `raise ImportError(...)`. We grep rather + than exec because actually running it would terminate the test.""" + src = TOMBSTONE.read_text(encoding="utf-8") + assert "raise ImportError(" in src, "tombstone does not raise ImportError" + + +def test_tombstone_contains_v2_install_hint() -> None: + src = TOMBSTONE.read_text(encoding="utf-8") + assert "pip install wifi-densepose==2.0.0" in src, ( + "tombstone ImportError message must include the v2 pip install hint" + ) + + +def test_tombstone_contains_migration_url() -> None: + src = TOMBSTONE.read_text(encoding="utf-8") + assert "docs/pip-migration.md" in src, ( + "tombstone must point users at the migration guide" + ) + + +def test_tombstone_is_minimal() -> None: + """The whole point of the tombstone is that it's MINIMAL — no + imports, no helper functions, no class definitions. Lock that + down so a well-intentioned refactor doesn't accidentally bloat it + into a real module that loads partway before failing.""" + src = TOMBSTONE.read_text(encoding="utf-8") + forbidden = ("def ", "class ", "import wifi_densepose", "import os", "import sys") + for f in forbidden: + assert f not in src, f"tombstone must not contain {f!r} — it should ONLY raise"