From e1df2e9feac20ed3a847bd206bb71ea73a8abd50 Mon Sep 17 00:00:00 2001 From: Berk Geveci Date: Thu, 5 Nov 2026 15:31:08 -0500 Subject: [PATCH] A publish workflow for tack-core, and CI that installs it alone The workflow publishes tack-core to PyPI on a release tag, after the pypi environment's approval, with trusted publishing; run by hand it uploads to TestPyPI, the dry run. It checks the tag is the version, builds the wheel and the sdist, twine-checks both and installs tack-core alone from each. CI's wheel job does the same checks on every pull request (tools/check_core_install.py). RELEASING.md has the setup and the steps. tack.dataset without tack-vis names the command that installs it from the repository at this release's tag. Split from #366, whose docs and backend messages (pip install tack-core) wait for the release. --- .github/workflows/ci.yml | 17 ++++- .github/workflows/publish.yml | 98 +++++++++++++++++++++++++ RELEASING.md | 52 +++++++++++++ changelog.d/publish-workflow.md | 16 ++++ packages/tack-core/src/tack/__init__.py | 11 ++- tools/check_core_install.py | 41 +++++++++++ 6 files changed, 231 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 RELEASING.md create mode 100644 changelog.d/publish-workflow.md create mode 100644 tools/check_core_install.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 652fdda43..22967ad11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -504,7 +504,9 @@ jobs: # Every other job runs the source tree. This one builds both wheels and # installs them in a fresh environment, as a user would, so a packaging # mistake (tack-vis shipping its own tack/__init__.py, which runs instead - # of tack-core's when it comes first) fails here. + # of tack-core's when it comes first) fails here. tack-core goes to PyPI + # alone (its sdist too), so it is also installed alone, from the wheel and + # from the sdist, and must run without tack-vis. wheels: name: Built wheels install and import runs-on: ubuntu-latest @@ -520,10 +522,11 @@ jobs: cache-dependency-glob: "**/uv.lock" python-version: "3.13" - - name: Build both wheels + - name: Build both wheels, and tack-core's sdist run: | - uv build --wheel --out-dir dist packages/tack-core + uv build --out-dir dist packages/tack-core uv build --wheel --out-dir dist packages/tack-vis + uvx twine check --strict dist/tack_core-* python3 - <<'EOF' import glob, zipfile vis = zipfile.ZipFile(glob.glob("dist/tack_vis-*.whl")[0]).namelist() @@ -547,6 +550,14 @@ jobs: packages/tack-vis/tests/test_namespace.py \ packages/tack-vis/tests/test_algorithm_contracts.py + - name: tack-core alone, from its wheel and from its sdist + run: | + for built in "$(ls dist/tack_core-*.whl)" "$(ls dist/tack_core-*.tar.gz)"; do + rm -rf /tmp/coreenv && uv venv -q /tmp/coreenv --python 3.13 + VIRTUAL_ENV=/tmp/coreenv uv pip install -q "${built}[cpu]" + (cd /tmp && /tmp/coreenv/bin/python "$GITHUB_WORKSPACE/tools/check_core_install.py") + done + # ── Examples ─────────────────────────────────────────────────────── # The shipped examples are the code new users copy, and they exercise # combinations the unit tests do not — templates feeding a solver, a diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 000000000..fe78ebd04 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,98 @@ +# Publish tack-core to PyPI. tack-vis is not published: users install it from +# this repository at the release tag (docs/install.md). +# +# - A release tag (v0.7.0) builds tack-core, checks it, and publishes it to PyPI +# once the `pypi` environment's reviewers approve. +# - Run by hand (Actions > Publish > Run workflow) it publishes the same build to +# TestPyPI instead: the dry run before a release. TestPyPI refuses a version it +# has, so a second dry run of one version needs a dev version +# (`0.7.0.dev1` in packages/tack-core/pyproject.toml) on a branch. +# +# Uploads use trusted publishing (GitHub's OIDC token): no API token is stored. +# PyPI and TestPyPI each have a trusted publisher for this repository, this +# workflow (publish.yml) and the environment (pypi, testpypi). + +name: Publish + +on: + push: + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build and check tack-core + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + python-version: "3.13" + + - name: The tag is the package's version + if: github.ref_type == 'tag' + run: | + version=$(python3 -c "import tomllib; print(tomllib.load(open('packages/tack-core/pyproject.toml', 'rb'))['project']['version'])") + test "v$version" = "$GITHUB_REF_NAME" || { + echo "::error::tag $GITHUB_REF_NAME, but tack-core's version is $version"; exit 1; } + + - name: Build the wheel and the sdist + run: | + uv build --out-dir dist packages/tack-core + uvx twine check --strict dist/* + ls -l dist + + - name: tack-core alone, from its wheel and from its sdist + run: | + for built in "$(ls dist/tack_core-*.whl)" "$(ls dist/tack_core-*.tar.gz)"; do + rm -rf /tmp/coreenv && uv venv -q /tmp/coreenv --python 3.13 + VIRTUAL_ENV=/tmp/coreenv uv pip install -q "${built}[cpu]" + (cd /tmp && /tmp/coreenv/bin/python "$GITHUB_WORKSPACE/tools/check_core_install.py") + done + + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + testpypi: + name: Publish to TestPyPI (run by hand) + if: github.event_name == 'workflow_dispatch' + needs: build + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/p/tack-core + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + + pypi: + name: Publish to PyPI (release tag) + if: github.ref_type == 'tag' + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/tack-core + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 000000000..8cf4d74f5 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,52 @@ +# Releasing Tack + +`tack-core` is published to PyPI by `.github/workflows/publish.yml`. `tack-vis` +is not published: users install it from this repository at the release tag. + +## Once: PyPI, TestPyPI and GitHub + +1. **PyPI and TestPyPI.** On each, add a trusted publisher for the project + `tack-core` (a "pending publisher" before the first upload): + - owner `Kitware`, repository `tack`; + - workflow `publish.yml`; + - environment `pypi` on PyPI, `testpypi` on TestPyPI. +2. **GitHub.** In the repository's settings, under Environments, add `pypi` + and `testpypi`. Give `pypi` required reviewers, so a tag cannot publish + without an approval. + +No API token is stored anywhere: the upload authenticates with GitHub's OIDC +token. + +## Each release + +1. **Versions.** + - Set `version` in `packages/tack-core/pyproject.toml` and + `packages/tack-vis/pyproject.toml` (say `0.7.0`). + - Raise tack-vis's `tack-core>=` requirement to the new version, so a + tagged tack-vis never pairs with an older tack-core from PyPI. +2. **The install commands.** They name the release tag (`@v0.7.0`): in + `docs/install.md`, `README.md` and `packages/tack-core/README.md`. +3. **The CHANGELOG.** Run `python tools/changelog.py release 0.7.0 YYYY-MM-DD`, + and write the release's summary in `CHANGELOG.md`. +4. **Merge** those changes to `main`, through a pull request as usual. +5. **The dry run.** Run Actions > Publish > Run workflow on `main`. It builds + and checks tack-core, then uploads it to TestPyPI. Then install it in a + clean environment: + + ```bash + python -m venv /tmp/t && /tmp/t/bin/pip install \ + --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ \ + 'tack-core[cpu]==0.7.0' + cd /tmp && /tmp/t/bin/python /path/to/tack/tools/check_core_install.py + ``` + + TestPyPI keeps a version once uploaded. To run a second dry run of one + version, upload a dev version (`0.7.0.dev1`) from a branch. +6. **Tag and push.** Push `v0.7.0` on the merge commit. The workflow checks + that the tag is the package's version, builds and checks again, and waits + for the `pypi` environment's approval. Approve, and it publishes. +7. **Check PyPI.** + - Run `pip install 'tack-core[cpu]'` in a clean environment, then the + tack-vis command from `docs/install.md`. + - The GPU extras are checked on the GPU machines (`[cuda]` on jura, `[hip]` + on illyad, `[level_zero]` on headroom). diff --git a/changelog.d/publish-workflow.md b/changelog.d/publish-workflow.md new file mode 100644 index 000000000..b3685a9f2 --- /dev/null +++ b/changelog.d/publish-workflow.md @@ -0,0 +1,16 @@ +### Added + +- A publish workflow for tack-core (`.github/workflows/publish.yml`). On a + release tag it checks the tag is the version, builds the wheel and the sdist, + `twine check`s them, installs tack-core alone from each, and uploads to PyPI + with trusted publishing after the `pypi` environment's approval. Run by hand, + it uploads to TestPyPI, the dry run. `RELEASING.md` has the one-time setup and + a release's steps. tack-vis is not published; it is installed from the + repository at the release tag. + +### Changed + +- CI's wheel job also builds tack-core's sdist, checks both with `twine check`, + and installs tack-core alone, from the wheel and from the sdist + (`tools/check_core_install.py`). Asking for `tack.dataset` without tack-vis + names the command that installs it from the repository. diff --git a/packages/tack-core/src/tack/__init__.py b/packages/tack-core/src/tack/__init__.py index c4d99c300..85d9097fe 100644 --- a/packages/tack-core/src/tack/__init__.py +++ b/packages/tack-core/src/tack/__init__.py @@ -192,6 +192,13 @@ def texture3d(source_array, shape=None, interp='linear'): #: Public submodules, loaded on first use, so ``import tack`` alone reaches #: ``tack.arrays.CountingArray``. ``dataset`` and ``interop`` are tack-vis's. +#: How to install tack-vis, which is not on PyPI: from the repository, at this +#: release's tag (a development install has no tag to name). +_TACK_VIS_INSTALL = ( + "pip install 'tack-vis @ git+https://github.com/Kitware/tack" + + ("" if "+" in __version__ or "dev" in __version__ else f"@v{__version__}") + + "#subdirectory=packages/tack-vis'") + _SUBMODULES = frozenset({"algorithms", "arrays", "cache", "communication", "math", "random", "dataset", "interop"}) @@ -205,7 +212,9 @@ def __getattr__(name): if error.name != f"{__name__}.{name}": raise raise AttributeError(f"module 'tack' has no attribute {name!r}: tack.{name} is " - f"in the tack-vis package, which is not installed") from error + f"in the tack-vis package, which is not installed. It is " + f"not on PyPI; install it from the repository:\n" + f" {_TACK_VIS_INSTALL}") from error raise AttributeError(f"module 'tack' has no attribute {name!r}") diff --git a/tools/check_core_install.py b/tools/check_core_install.py new file mode 100644 index 000000000..ef0ae5afd --- /dev/null +++ b/tools/check_core_install.py @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: Copyright (c) Kitware Inc. +# SPDX-License-Identifier: BSD-3-Clause + +"""Check an installed tack-core with no tack-vis, as PyPI users get it: +``python tools/check_core_install.py`` from outside the source tree. + +tack is the installed package (not a source tree on the path), a kernel runs on +the CPU, and ``tack.dataset`` says that tack-vis is not installed and how to +install it from the repository.""" + +import sys + +import numpy as np + +import tack + +assert "site-packages" in tack.__file__, f"tack comes from {tack.__file__}, not an install" +tack.set_default_device("cpu") + + +@tack.kernel +def double(x, out): + for i in range(x.shape[0]): + out[i] = 2.0 * x[i] + + +x = tack.arange(8, dtype=tack.f32) +out = tack.empty_like(x) +double(x, out) +assert np.array_equal(out.numpy(), 2 * np.arange(8, dtype=np.float32)), out.numpy() + +try: + tack.dataset # noqa: B018 (the attribute is the check) +except AttributeError as error: + message = str(error) + assert "tack-vis" in message and "git+https://github.com/Kitware/tack" in message, message +else: + sys.exit("tack.dataset imported: tack-vis is installed, so this checks nothing") + +print(f"OK: tack-core {tack.__version__} alone runs a kernel on the CPU, and " + "tack.dataset names tack-vis's install")