Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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
Expand Down
98 changes: 98 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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
52 changes: 52 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -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).
16 changes: 16 additions & 0 deletions changelog.d/publish-workflow.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 10 additions & 1 deletion packages/tack-core/src/tack/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})

Expand All @@ -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}")


Expand Down
41 changes: 41 additions & 0 deletions tools/check_core_install.py
Original file line number Diff line number Diff line change
@@ -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")
Loading