Skip to content

Publish Dion to PyPI from a v* tag via Trusted Publishing - #118

Open
John (JohnLangford) wants to merge 2 commits into
microsoft:mainfrom
JohnLangford:jcl/pypi-release
Open

Publish Dion to PyPI from a v* tag via Trusted Publishing#118
John (JohnLangford) wants to merge 2 commits into
microsoft:mainfrom
JohnLangford:jcl/pypi-release

Conversation

@JohnLangford

Copy link
Copy Markdown
Contributor

Closes#116.

Dion is installable only from git today. A project that itself publishes to PyPI therefore cannot declare dion as a dependency at all — PyPI rejects direct-URL (git+https://) requirements in uploaded distributions. vllm-project/speculators#1031 is a live example, working around it with a lazy import and a manual install message:

# Imported lazily on purpose: dion is not on PyPI, so it cannot be a# declared dependency and may legitimately be absent.fromdionimportDion3

The dion name is currently unclaimed on PyPI, so publishing also secures it.

What this adds

.github/workflows/release.yml — the repo has no .github/ today, so this is the first workflow.

  • On any PR touching packaging: builds the sdist and wheel, runs twine check --strict, and runs the packaging tests against both the source and the built artifacts.
  • On a v* tag: rebuilds, verifies the tag matches setup.py's version (so v0.2.0 cannot publish 0.1.0), and publishes via Trusted Publishing — OIDC against a pypi environment, no API token in the repo.

RELEASING.md documents the one-time PyPI-side setup and how to cut a release.

Two bugs this had to fix first

Both build cleanly and pass twine check, which is why neither has surfaced.

1. author_email does not parse, and PyPI validates it on upload.

author_email="{kwangjunahn, byronxu}@microsoft.com", # left this form to prevent bots from harvesting emails

email.utils.parseaddr returns ('', '') for this. The first upload would have been rejected at the very last step, after everything else succeeded. Those addresses no longer reach anyone regardless. The author list is paper attribution and is unchanged — only the contact address is now live, and the new project_urls sends bug reports to the issue tracker rather than to an inbox.

2. The sdist shipped without the requirements_*.txt files — silently.

setup.py reads them at build time. There was no MANIFEST.in, so they were not in the sdist, and read_requirements warns and returns [] when a file is missing. The build therefore succeeds and produces a wheel that declares no dependencies at all. Round-tripped to confirm:

Wheel built from the sdistBase Requires-Dist
before(none) — no numpy, no torch
afternumpy, torch>=2.7.1

The py3-none-any wheel means most users would never hit it, but --no-binary, downstream packagers, and anyone building from source would have gotten a dependency-less install.

Tests

tests/test_packaging.py covers both. It parses setup.py with ast, so it needs neither torch nor a build step, and CI re-runs it against the real artifacts via DION_DIST_DIR. twine check inspects only the long description and catches neither bug, so this is the only guard.

Each test was confirmed to fail against the pre-fix state and pass after.

Also adds the License :: OSI Approved :: MIT License and Python-version classifiers (PyPI facets on these), and a pyproject.toml[build-system] table so builds stop going through the deprecated setup.py bdist_wheel path.

Before the first release can happen

Two steps need someone with the accounts; neither can be done from a PR. Both are in RELEASING.md:

  1. Register a pending publisher on pypi.org — project dion, owner microsoft, repo dion, workflow release.yml, environment pypi. A pending publisher is what allows the workflow to create a project that does not exist yet.
  2. Create the pypi environment in Settings → Environments. Worth adding required reviewers so a human approves each upload.

Nothing is tagged or published by this PR. CHANGELOG.md's [Unreleased] section is deliberately left alone — cutting it belongs with the version bump in the release PR. The README still documents the git+ install, which stays accurate until the first release lands.

John Langford added 2 commits August 24, 2026 13:05
Dion is installable only from git today, so a downstream project that
publishes to PyPI cannot declare it as a dependency at all -- PyPI rejects
direct-URL requirements in uploaded distributions. vllm-project/speculators#1031
works around this with a lazy import and a manual install message. Requested in microsoft#116.
Adds .github/workflows/release.yml: every change to packaging builds and
validates the distributions, and a v* tag publishes them. Publishing goes
through PyPI Trusted Publishing (OIDC) against a `pypi` environment, so no API
token lives in this repo. The tag is checked against setup.py's version before
anything is uploaded. RELEASING.md documents the one-time PyPI-side setup and
the steps to cut a release.
Two things had to be fixed first; both build cleanly and pass `twine check`,
which is why neither has been noticed:
- author_email was `{kwangjunahn, byronxu}@microsoft.com`, deliberately
obfuscated against harvesters. It does not parse as an address
(email.utils.parseaddr returns ('', '')) and PyPI validates the field on
upload, so the first release would have been rejected at the very last step.
Those addresses no longer reach anyone in any case. The author list is paper
attribution and is unchanged; only the contact is now live, and project_urls
points bug reports at the issue tracker instead of an inbox.
- MANIFEST.in did not exist, so the sdist shipped without the requirements_*.txt
files that setup.py reads at build time. read_requirements warns and returns
[] when they are absent, so building from the sdist *succeeds* and produces a
wheel declaring no dependencies at all -- no numpy, no torch. Verified by
round-tripping: before, an sdist-built wheel had zero base Requires-Dist;
after, it carries numpy and torch>=2.7.1.
tests/test_packaging.py covers both. It reads setup.py with ast, so it needs
neither torch nor a build, and re-runs against the built artifacts in CI via
DION_DIST_DIR -- twine check inspects only the long description and would not
catch either bug. Also adds the MIT and Python-version classifiers, which PyPI
facets on, and a pyproject.toml [build-system] table so builds stop going
through the deprecated `setup.py bdist_wheel` path.
Review follow-ups on the PyPI publishing workflow.
Supply chain: pin every action to a commit SHA. The publish job holds an
OIDC identity PyPI trusts to upload as `dion`, and
`pypa/gh-action-pypi-publish@release/v1` is a mutable *branch* -- a
compromise of it runs code inside that job. Adds .github/dependabot.yml so
the pins do not rot, and `persist-credentials: false` on the checkout,
which does not need to keep a token in .git/config.
Tag gate: verify the tag against the version baked into the built
distribution filenames rather than re-parsing setup.py with sed. That is
the version that will actually be uploaded, it removes the second parser
for the same field, and comparing PEP 440 Versions rejects a tag that is
not a valid version at all.
PR trigger: the paths filter missed README.md -- which is the
long_description that `twine check --strict` renders, so a README change
could break a release with no pre-tag signal -- along with the other files
MANIFEST.in ships and `dion/**`, whose layout find_packages() reads.
MANIFEST.in: glob `requirements_*.txt` instead of listing the four files,
so a requirements file added later is shipped rather than merely detected
as missing. The test now matches names against the include patterns.
Tests:
- test_wheel_ships_every_package_module -- nothing checked that the wheel
contains the code. find_packages() silently drops a subdirectory with no
__init__.py, and CI builds the wheel from the sdist, so this covers both.
- test_python_classifiers_agree_with_python_requires -- the classifiers
advertise 3.9-3.13 with nothing tying them to python_requires.
- test_repo_has_requirements_files -- an empty glob made the parametrized
manifest test vacuous, which is the silent-pass failure this file exists
to prevent.
- import packaging directly instead of pytest.importorskip; pytest depends
on it, so the skip could only ever hide the PEP 440 check.
RELEASING.md: document that Actions must be enabled for repo-file
workflows here -- no workflow under .github/ has ever run in this
repository, and release.yml did not trigger on its own pull request -- plus
why `python -m build` is spelled without flags (only the no-flag form
builds the wheel from the sdist, which is the round trip the dependency
assertions rely on), the SHA pinning, and that a `v*` tag alone reaches the
upload step.
Sign up for freeto 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.

Publish Dion to PyPI

1 participant

@JohnLangford