Skip to content

ci: add a PyPI release workflow, and modernize the license declaration - #5

Merged
dimitri-yatsenko merged 4 commits into
mainfrom
ci/pypi-release
Aug 21, 2026
Merged

ci: add a PyPI release workflow, and modernize the license declaration#5
dimitri-yatsenko merged 4 commits into
mainfrom
ci/pypi-release

Conversation

@dimitri-yatsenko

Copy link
Copy Markdown
Member

This repo had no workflows, so nothing was built, checked, or published automatically — and it is not on PyPI, while the README tells readers to pip install dj-figpack-codecs. That instruction currently fails, and this is the package the forthcoming blog post points readers at.

The workflow

Builds on every push and PR via build-and-inspect-python-package; publishes only on a published GitHub release.

Pinned to v3.0.1 for a specific reason: its Twine 7 understands the packaging metadata a PEP 639 license declaration produces. The v2 line bundles Twine 6, which fails twine check --strict with "'2.5' is not a valid metadata version" — the failure that had dj-zarr-codecs red, diagnosed in that repo's #9. Copying @v2 here would have imported the same bug.

Trusted publishing, not a stored token. PyPI verifies the workflow's OIDC identity, so there is no long-lived secret to leak or rotate. The job also attaches a build-provenance attestation.

⚠️ One manual step before the first release

A pending publisher must be configured on PyPI for this project name. The workflow cannot create it, and the first release will fail without it. At pypi.org publishing settings:

Field Value
PyPI project name dj-figpack-codecs
Owner datajoint
Repository dj-figpack-codecs
Workflow cd.yml
Environment pypi

The environment: pypi in the job must match that last field.

License declaration modernized in the same change

Deliberately bundled, because a release writes metadata to PyPI permanently — a version cannot be re-uploaded. A wrong first release cannot be replaced.

  • Deprecated license = { file = "LICENSE" } → PEP 639 SPDX expression plus license-files
  • Redundant License :: OSI Approved classifier removed — current backends reject carrying both an SPDX expression and a license classifier
  • Build requirement raised to hatchling>=1.27 for PEP 639 support

Verified by building rather than assuming: the wheel declares License-Expression: Apache-2.0 with License-File: LICENSE captured, and passes twine check --strict.

Not included

No test/lint workflow. This repo does have tests (5 files), so dj-zarr-codecs' ci.yml is worth copying here — but it depends on a .pre-commit-config.yaml this repo lacks, so it is a separate change rather than something to bolt on.

This repo had no workflows at all, so nothing was built, checked, or published
automatically -- and it is not on PyPI, while its README tells readers to pip install it.

The workflow builds on every push and pull request via
build-and-inspect-python-package (pinned to v3.0.1, whose Twine 7 understands the
packaging metadata a PEP 639 license produces; the v2 line's Twine 6 does not), and
publishes only on a published GitHub release.

Publishing uses PyPI trusted publishing rather than a stored token: PyPI verifies the
workflow's OIDC identity, so no long-lived secret exists to leak. It also attaches a
build-provenance attestation. This requires a pending publisher to be configured on
PyPI for the project name before the first release -- the workflow cannot create that,
and the first release will fail without it.

The license declaration is modernized in the same change, deliberately, because a
release writes metadata to PyPI permanently and a version cannot be re-uploaded. The
deprecated license table becomes a PEP 639 SPDX expression with license-files, and the
redundant "License :: OSI Approved" classifier is removed -- current backends reject
carrying both. Verified by building locally: the wheel now declares
License-Expression: Apache-2.0 with the LICENSE captured, and passes twine check
--strict.
Two problems, both making the package claim something untrue.

The classifiers stopped at 3.12. datajoint 2.3.2 classifies 3.10 through 3.14, so
this package was telling PyPI it did not support the two most recent Pythons, including
the current stable release.

There was no upper bound. datajoint pins <3.15; this package did not, so pip would
happily install it on 3.15 into an environment where its only real dependency cannot
be installed at all. requires-python is now >=3.10,<3.15, matching datajoint exactly
rather than approximating it -- the floor and the ceiling both come from the dependency
that determines them.

Verified by building: Requires-Python: <3.15,>=3.10, classifiers through 3.14, and
twine check --strict passes.
@dimitri-yatsenko

Copy link
Copy Markdown
Member Author

Python range updated in a follow-up commit. Two problems, both making the package claim something untrue.

Classifiers stopped at 3.12. datajoint 2.3.2 classifies 3.10 through 3.14, so this package was telling PyPI it did not support the two most recent Pythons — including the current stable release. Added 3.13 and 3.14.

There was no upper bound. datajoint pins <3.15; this package did not. pip would therefore install it happily on 3.15 into an environment where its only real dependency cannot be installed at all — an install that succeeds and then fails at import.

requires-python is now >=3.10,<3.15, matching datajoint exactly rather than approximating it. Both ends come from the dependency that determines them, so the next time datajoint moves, the correct edit here is obvious.

Verified by building rather than assuming:

Requires-Python: <3.15,>=3.10
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
License-Expression: Apache-2.0

twine check --strict passes.

One inconsistency left in the family, flagged rather than changed: dj-zarr-codecs declares >=3.11 with no cap, and its mypy targets 3.12. Its floor is stricter than datajoint's — legitimate if deliberate, since current zarr and numpy require 3.12 — but it should probably gain the <3.15 cap too. That is a separate PR against a repo with an open licensing PR, so I have not touched it here.

The cap I added is unnecessary and would cause the failure mode it was meant to
prevent. Removing it.

My justification for it was wrong. I claimed that without a cap, pip on 3.15 would
install this package into an environment where datajoint cannot be installed. It will
not: this package pins datajoint>=2.0, and a dependency's requires-python is honored
during resolution, so pip fails there with an error that names datajoint -- the accurate
diagnosis. Verified by resolving into a 3.9 environment: `datajoint>=2.0` fails cleanly
with "datajoint>=2.0.0 cannot be used", while bare `datajoint` silently resolves to
0.14.9, an ancient release predating 2.0.

That silent downgrade is the argument against caps. requires-python is a resolution
input, so a capped release is skipped rather than reported: on a Python the cap excludes,
pip installs an older release of this package instead of saying why. And a published cap
cannot be relaxed -- when 3.15 ships and works, every capped version still refuses it,
and support requires a new release rather than nothing at all.

The classifiers stay at 3.10 through 3.14, which is the range actually tested and
claimed. Classifiers are documentation and carry no resolution behavior, so they can say
what is verified without constraining what is possible.
The version was declared statically in pyproject.toml, and in this package's
__init__.py as well, so two files had to agree with each other and with whatever tag a
release carried. Nothing enforced that, and a release where they disagree is the kind of
bug that is only visible after publishing.

Now hatchling plus hatch-vcs, matching dj-zarr-codecs: the git tag is the single source
of truth, the build hook writes _version.py, and the package imports it. A release is a
tag, and no pull request touches a version number -- which is what we want anyway.

Consequence worth stating plainly: the previously declared version is gone. Until a tag
exists the build produces a development version derived from the commit distance, and
the first tag is what fixes the number. That tag is a decision, not a carry-over.

Also adds a .gitignore, which this repo did not have, covering the generated
_version.py and the usual build artifacts.
@dimitri-yatsenko
dimitri-yatsenko merged commit ab2b554 into main Aug 21, 2026
2 checks passed
@dimitri-yatsenko
dimitri-yatsenko deleted the ci/pypi-release branch August 21, 2026 19:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant