Contributing and release checks¶
Setting up¶
[dev] pulls in the test and lint tooling and the [examples] extra. Add [docs] if you are
working on this site.
The checks¶
pytest tests/ -q
ruff check complexplorer/ tests/ examples/ scripts/
ruff format --check complexplorer/ tests/ examples/ scripts/
openspec validate --specs
mkdocs build --strict
Ruff is pinned to a minor version deliberately: a formatter release once failed this gate with no change to the repository, and a gate that breaks on its own tooling teaches people to ignore it.
Note that ruff format --check reports failure in its output but its exit code can be masked by a
shell pipeline — read the output, not just the status.
Spec-driven changes¶
Behaviour is specified before it is written. Specs live in openspec/specs/, organised into
capabilities; a change starts as a proposal in openspec/changes/<name>/ with proposal.md,
design.md, tasks.md and delta specs, and is archived into the baseline when it lands.
For anything non-trivial, write the change first. openspec validate <change> checks it, and
openspec archive <change> applies the deltas to the baseline specs.
The notebooks¶
Not part of the default suite — the notebooks drive PyVista and take minutes. They run on a schedule, on demand, and on every release tag.
Regenerating the gallery¶
Every image on this site and in the repository comes from that command, via the recipes in
examples/gallery/showcase.json. Assets are promoted by regenerating, never by copying a file in
by hand — that is what keeps "every committed asset reproduces from a documented command" true.
Manifests (index.json, card.json) are byte-stable for a given library version on any platform,
so a regeneration that changes them is telling you something real. Portrait PNGs are reproducible
only best-effort and will differ across machines.
"For a given library version" is literal: index.json and showcase.json record
complexplorer_version, so a version bump invalidates the committed manifest until the gallery is
regenerated. Bump first, then regenerate — the other order leaves a release commit whose suite is
red.
Releasing¶
A release is a tag. Pushing vX.Y.Z runs .github/workflows/release.yml, which checks the tag
against the packaged version, runs the artifact gate below, uploads to TestPyPI, and then waits for
an approval before uploading to PyPI. Merging to main publishes nothing: main carries
documentation, chores and specification archives, and a condition that suppressed a publish for
those would have to be right every time against a mistake PyPI can yank but never undo.
- Bump
__version__incomplexplorer/_version.py, andCITATION.cffto match. The workflow compares the tag against__version__and fails before building if they disagree. - Regenerate the gallery, after the bump, for the reason given above.
- Add the changelog entry and date it.
- Land all of that, then tag the landed commit and push the tag:
git tag vX.Y.Z && git push origin vX.Y.Z. - Approve the
pypienvironment when the run asks. TestPyPI publishes without an approval; PyPI waits for a person.
Neither guard replaces the other. TestPyPI surfaces configuration faults — a publisher entry that
does not match, metadata an index rejects that twine check accepts — against an index where a
version number can be spent harmlessly. The approval surfaces intent faults, such as a tag at the
wrong commit or ahead of a pending fix, which produce a perfectly valid artifact that no automated
check can distinguish from a correct release.
Both uploads authenticate through PyPI's Trusted Publishing, so there is no API token in repository secrets or in anyone's password manager.
The version bump, the changelog entry and the GitHub Release notes stay manual. Inferring a bump from commit messages is a guess about semantics this project makes deliberately — 3.0 removed the matplotlib 3D backend, and no commit-message convention would have decided that was a major. The tag gate verifies the bump rather than writing it.
A bad release is recovered with a new version, never a retry. A published version is immutable, so the workflow fails on a version already on the index rather than skipping it: a green run that uploaded nothing would answer "did my fix ship?" incorrectly.
The wider runbook — joining the histories, the notebook run, the gallery regeneration,
CITATION.cff, the GitHub release — is in
CONTRIBUTING.md in the
repository.
The release artifact gate¶
The test suite runs against the source tree, so it cannot see what a user installs; these checks look at the artifact itself. CI runs them on every push, and the release workflow runs them again against the exact files it then publishes — nothing is rebuilt between the gate and the upload. To reproduce the gate locally:
# Build, and check the metadata the way PyPI will.
uv build
uv run --no-project --with twine twine check dist/*
# Inspect the distributions: contents, licences, py.typed, metadata, entry point.
python scripts/check_distribution.py dist
# Install the WHEEL into a throwaway environment - not the checkout.
uv venv /tmp/smoke --python 3.12
uv pip install --python /tmp/smoke/bin/python dist/*.whl
# Smoke it from somewhere else: import, CLI, 2D render, off-screen 3D render, STL export.
cd /tmp && /tmp/smoke/bin/python "$OLDPWD/scripts/smoke_wheel.py"
On Windows use /tmp/smoke/Scripts/python.exe. The smoke script refuses to run against a source
tree, because that is the mistake it exists to catch.
This gate is not ceremonial. On its first run it found two crashes that the 695-test suite could
not see, both on Windows consoles using a legacy code page: complexplorer list and STL export
each ended in UnicodeEncodeError. The tests cover the library; the gate covers the product.
CI¶
Every push runs lint, the suite on Linux and Windows across the supported Python versions plus macOS, a lane that installs the declared minimum dependency versions, a non-blocking lane on the next Python, the artifact gate, and a strict documentation build. A weekly run repeats the suite against the newest dependencies so an upstream change surfaces away from a release.