PyPI Release Guide¶
This is the single source of truth for releasing PocketLLM to PyPI. Any other release note that disagrees with this file is out of date.
Prerequisites¶
1. PyPI account and API tokens¶
Production and Test PyPI are separate services with separate accounts:
- Production: https://pypi.org/account/register/ and https://pypi.org/manage/account/token/
- Test: https://test.pypi.org/account/register/ and https://test.pypi.org/manage/account/token/
Create a token for each. The first publish for a project has to use an account-scoped token, because a project-scoped token cannot be created until the project exists.
2. ~/.pypirc¶
[distutils]
index-servers =
pypi
testpypi
[pypi]
username = __token__
password = pypi-AgE...your-production-token-here
[testpypi]
repository = https://test.pypi.org/legacy/
username = __token__
password = pypi-AgE...your-test-token-here
3. Build tools¶
4. Clean working directory¶
Pre-release checklist¶
- [ ] Bump the version in both
pyproject.tomlandpocketllm/__init__.py - [ ] Update
CHANGELOG.mdwith the release notes - [ ] Update
README.mdif the release changes installation or requirements - [ ] Run the tests:
python -m pytest tests/test_install_smoke.py tests/test_native_build_preflight.py tests/test_sdist_native_sources.pytest_sdist_native_sources.pyis the one that guards the packaging step rather than the code: it checks that every sourcecpp_engine/CMakeLists.txtdeclares is in the sdist. - [ ] Confirm the two version strings agree:
python -c "import re, pathlib, pocketllm; print(pocketllm.__version__, re.search(r'^version = \"(.+)\"', pathlib.Path('pyproject.toml').read_text(), re.M).group(1))"The regular expression is not stylistic:tomllibis 3.11+, while this package supports 3.10, so a checklist step that imports it cannot be run on the oldest supported interpreter. The publish workflow reads the version the same way. This one does belong in the checkout — it compares the two version strings the source declares, and both sides come from the tree. The install steps below are the ones that must not run here. - [ ] Commit all changes:
git commit -m "Release v0.x.x" - [ ] Create the tag:
git tag v0.x.x
Build the distribution¶
1. Clean previous builds¶
2. Build the source distribution¶
--no-isolation is required:
- setup.py needs the active environment's PyTorch
- CUDA extensions must compile against the host's CUDA toolkit
- An isolated build environment resolves a Torch that may not match that toolkit
3. Check the package¶
4. Install the built sdist locally¶
REPO=$PWD # run this from the repository root
rm -rf /tmp/test-pocketllm
python -m venv /tmp/test-pocketllm
source /tmp/test-pocketllm/bin/activate
# --no-build-isolation tells pip not to provision [build-system].requires, so the
# venv has to already have what the build imports. A new venv has none of these,
# and the two ways that shows up are under Troubleshooting.
python -m pip install --upgrade pip
python -m pip install "torch>=2.0,<2.7" "setuptools>=68" wheel ninja cmake pybind11
pip install dist/pocketllm-0.x.x.tar.gz --no-build-isolation
cd /tmp
python -c "import pocketllm; print(pocketllm.__version__, pocketllm.__file__)"
python -c "import pocketllm_cpp; print('C++ engine: OK', pocketllm_cpp.__file__)"
python "$REPO/tests/test_install_smoke.py"
deactivate
cd /tmp is not incidental. python -c puts the working directory on sys.path, so run from the
checkout the first line imports pocketllm/ from the source tree and prints the version the tree
has — which is what the release is bumping, so it agrees with the artifact by construction and proves
nothing. Both lines print the module path for the same reason: it has to be under the virtualenv.
The smoke test is run by path rather than by name, which puts the test's own directory, not the
checkout, on sys.path.
This is the only step that proves the artifact installs. It compiles CUDA extensions and the native C++ engine, so it needs a machine with a CUDA toolkit — a CI runner without one cannot do it.
Upload¶
Step 1: Test PyPI¶
Uploading here is always the first upload of a release, without exception.
Step 2: Verify what Test PyPI actually serves¶
Do not stop at a successful upload. The 0.1.0 release uploaded cleanly, passed twine check, and
still shipped a distribution whose metadata claimed MIT while its rendered description said PolyForm
Noncommercial — because the description is README.md, and twine check does not read it.
Query the index for the metadata it is serving:
python - <<'PY'
import json, urllib.request
version = "0.x.x" # the version just uploaded
with urllib.request.urlopen(f"https://test.pypi.org/pypi/pocketllm/{version}/json") as r:
info = json.load(r)["info"]
print("version: ", info["version"])
print("license: ", info["license"])
print("rendered description mentions PolyForm:", "PolyForm" in (info.get("description") or ""))
PY
Expected: the version just uploaded, license: MIT, and False. The same check runs automatically as
the "Verify the published metadata" step of the publish workflow.
Step 3: Install from Test PyPI¶
REPO=$PWD # run this from the repository root
rm -rf /tmp/test-pocketllm
python -m venv /tmp/test-pocketllm
source /tmp/test-pocketllm/bin/activate
# Test PyPI does not carry Torch; range as declared in pyproject.toml. These are
# the build imports as well -- see step 4 for why the venv needs them first.
python -m pip install --upgrade pip
python -m pip install "torch>=2.0,<2.7" "setuptools>=68" wheel ninja cmake pybind11
# Pin the exact version under test. Unpinned, both indexes are searched and pip
# takes the highest version it can see, so this can quietly install a different
# release than the one just uploaded -- see Troubleshooting.
pip install --index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ \
"pocketllm==0.x.x" --no-build-isolation
cd /tmp # otherwise the checkout shadows the installed package; see step 4
python -c "import pocketllm; print(pocketllm.__version__, pocketllm.__file__)"
python -c "import pocketllm_cpp; print('C++ engine: OK', pocketllm_cpp.__file__)"
python "$REPO/tests/test_install_smoke.py"
deactivate
This is the step that catches an sdist that is missing files it needs to compile — the archive resolves, downloads and installs, and the failure is at build time.
The version is pinned for a reason. --extra-index-url does not scope the requirement to Test PyPI:
pip searches both indexes and takes the highest version it can see, so a listing that does not
mention the version just uploaded resolves to whatever production PyPI has instead, without a word.
Pinning turns that into No matching distribution found, which is a failure you can act on. See
Troubleshooting for the way this actually happens.
Step 4: Production PyPI¶
Once uploaded, a version cannot be deleted or replaced. A mistake there is recoverable only by yanking the release and publishing the next patch version.
Verify at https://pypi.org/project/pocketllm/ and re-run the metadata query above against
https://pypi.org/pypi/pocketllm/0.x.x/json.
Automated publishing (GitHub Actions)¶
.github/workflows/publish-pypi.yml runs the same sequence on a runner: build the sdist,
twine check, upload to Test PyPI, verify the metadata Test PyPI serves, and only then optionally
upload to production.
It is workflow_dispatch-only and the production upload is gated on the publish_production input,
which defaults to no. Leave it at no for a release candidate, inspect the verification step, and
re-run with yes once the metadata is known good.
Repository secrets the workflow needs:
| Secret | Purpose |
|---|---|
TEST_PYPI_API_TOKEN |
Upload to Test PyPI |
PYPI_API_TOKEN |
Upload to production PyPI |
Until both exist the workflow fails at its upload step. The workflow cannot install-test the artifact: GitHub-hosted runners have no CUDA toolkit, and the sdist compiles CUDA extensions, so installation verification stays a local step.
Post-release¶
Do this only after the artifact is on production PyPI. The GitHub release links to a version that
must already exist, and it cannot be created from a commit that is not on master — if the release
was prepared on a branch, merged first and pull master so the tag has somewhere to land.
- Push the tag and create the GitHub release
git push origin master
git push origin v0.x.x
# Release notes are this version's changelog section, not the whole file. Passing
# CHANGELOG.md directly publishes every previous release's notes as well; see
# Troubleshooting.
VERSION=0.x.x
awk -v hdr="## [$VERSION]" '
index($0, hdr) == 1 { found = 1 }
found && /^## \[/ && index($0, hdr) != 1 { exit }
found
' CHANGELOG.md > /tmp/notes-$VERSION.md
gh release create v$VERSION \
--title "PocketLLM v$VERSION" \
--notes-file /tmp/notes-$VERSION.md \
dist/pocketllm-$VERSION.tar.gz
The comparison is on the bracketed header rather than a regular expression on purpose: an
unescaped [0.x.x] is a character class, and index on ## [0.1.1] also declines to match a
## [0.1.10] header.
- Bump the version for development
Set the next development version in pyproject.toml and pocketllm/__init__.py, then commit:
Skipping this makes the next release indistinguishable from the one just published.
Troubleshooting¶
The install verification stops at invalid command 'bdist_wheel'¶
The venv is too bare for --no-build-isolation, which is the expected state of a venv that has just
been created. python -m venv bootstraps pip and setuptools from the interpreter's bundled
ensurepip; on Python 3.10 with pip 22.3.1 that is setuptools 65.5.0, which predates the version
that ships bdist_wheel as a built-in command — before setuptools 70.1 the command comes from the
separate wheel distribution, which a venv does not install. Nothing is built before this fails.
pyproject.toml declares both in [build-system].requires and setuptools>=68 in
[project].dependencies, but pip builds the wheel before installing the project's runtime
dependencies, so those declarations cannot supply the build that needs them.
The install verification stops at "pybind11 is not importable"¶
Same cause, one step later: cmake, pybind11 and ninja are also build imports, and the venv has
none of them. The message comes from this package's own preflight, which is reporting the venv's
toolchain rather than anything about the archive — it is not a sign that the artifact is broken.
Install them and retry:
A venv created with --system-site-packages hides both of these failures, because it inherits the
base interpreter's setuptools and toolchain. That makes it useless for this check: it will report
success for an artifact the documented procedure cannot install.
The install verification builds an older release than the one just uploaded¶
The step fails somewhere that has nothing to do with this release — CMake configuration, a source file no target names — or installs successfully and then reports the previous version.
pip caches the simple-index page. If https://test.pypi.org/simple/pocketllm/ was fetched before
this release was uploaded, pip reuses that listing; it has no link for the new version, so the
resolver falls back to the version production PyPI is offering. The published index is correct — only
the cached copy is old. It is visible without installing anything:
If that stops at the previous release while https://test.pypi.org/pypi/pocketllm/<version>/json
answers for the new one, this is what happened. Passing a fresh cache directory forces a real fetch:
The version pin in step 3 is what keeps this from being silent: with it, a listing that does not
carry the release under test fails with No matching distribution found rather than installing
something else. Add --no-cache-dir to the install command to bypass the cache as well.
The GitHub release notes contain the whole changelog¶
gh release create --notes-file CHANGELOG.md publishes the entire file. The v0.1.1 release notes
built that way would be 216 lines: the 0.1.1 section, then the 0.1.0 section with its feature list
and performance table, then the link definitions. A reader looking for what changed in this release
has to find it.
Pass the extracted section instead, as the post-release step does. The same applies to
--notes "$(cat CHANGELOG.md)".
ModuleNotFoundError: No module named 'torch' during the build¶
Use --no-build-isolation:
CUDA extension compilation fails during pip install¶
- Check the toolkit:
nvcc --version - Install PyTorch first:
pip install torch - Install with
pip install pocketllm --no-build-isolation
Native C++ engine build fails¶
The build stops deliberately rather than continuing without pocketllm_cpp, because a silently
missing native module lets backend="auto" fall back to Torch kernels with no error to explain the
change in behaviour. The failure message names the missing prerequisite.
To install the PyTorch backend only:
Build takes too long¶
The full build (CUDA extensions plus C++ engine) takes 5-15 minutes on a first install. Subsequent installs reuse cached builds where possible.
Version numbering¶
PocketLLM follows Semantic Versioning:
- Major (
x.0.0): breaking API changes - Minor (
0.x.0): new features, backward compatible - Patch (
0.0.x): bug fixes, backward compatible
Pre-releases: 0.1.0a1 (alpha), 0.1.0b1 (beta), 0.1.0rc1 (release candidate), 0.1.0.dev0
(development).
Known limitations of the published artifacts¶
- No pre-built wheels. Users compile extensions during installation, so they need a CUDA toolkit and 5-15 minutes.
- CUDA versions. Extensions compile against the user's toolkit. 11.8, 12.1, and 12.4 are exercised; anything else is untested.
- Platform. Linux only (Ubuntu 22.04 tested). Windows and macOS are untested.
Future improvements:
- [ ]
cibuildwheelfor pre-built wheels (CUDA version × Python version matrix) - [ ] Windows support
- [ ] macOS CPU-only support