Repository navigation
ci: publish releases to PyPI from CI, and test through Python 3.14 - #2
Merged
Merged
Conversation
There was no release workflow. CI tested every push and audited the built artifacts, but nothing published: the only route to PyPI was a maintainer running `twine upload` from a workstation, with a long-lived API token, against whatever the working tree happened to contain. docs/publishing.rst documented exactly that and nothing else. release.yml is ported from glpi_python_client's, which already solves this. Trusted Publishing rather than a stored token: the upload job declares `id-token: write` and PyPI mints a short-lived credential from its OIDC identity, so the repository holds no publishing secret at all. The `pypi` environment is what that publisher is scoped to, and where a required-reviewer gate on the upload belongs if the project ever wants one. The workflow gates the upload behind the full test matrix, Ruff, mypy, the generated-_sync/ check, the hand-written-twin lint and a warnings-as-errors Sphinx build, then validates that the release tag, `pyproject.toml` and `easyvista_python_client.__version__` all agree before building. Two of those deserve a reason. The version gate exists because PyPI takes whatever `pyproject.toml` says and ignores the tag. A tag that disagrees publishes a version nobody can find by the name it was announced under, and a `__version__` that disagrees misreports at runtime -- neither is fixable after upload, only yankable. The _sync/ and twin-lint gates are re-run here rather than trusted from ci.yml because this is the run that uploads. A stale generated sync tree builds, installs and imports cleanly; it is wrong only at the call site, which makes it precisely the kind of defect that reaches users. ci.yml covers pushes to main, so a release cut from main is already checked -- this makes that property hold for the ref actually being released. Docs follow the package, not the other way round: publish-readthedocs waits on publish-pypi succeeding on a release event, so the documented version is one that exists. It self-skips when the Read the Docs secrets are absent, so an unconfigured docs integration cannot fail a release whose package already uploaded. Concurrency is deliberately not cancel-in-progress -- a cancelled upload can leave a release half-published. `workflow_dispatch` runs everything except the upload (publish-pypi is gated on the event being a release), so a release can be rehearsed before it is tagged. The matrices in both workflows now span 3.10--3.14, and the classifiers say so. This is not a free line to add here: the _sync/ check is a byte-equality comparison against output that tokenize-rt reconstructs from the source, and both it and unasync are exact-pinned for that reason, so a tokenizer change in a new interpreter can fail it on that version alone. So it was measured, not assumed -- 3.13.14 and 3.14.6, both final releases: 600 passed, coverage 99.21% against the same 1270 statements as on 3.10 (no version-gated code paths appeared), and _sync/ regenerated byte-identical under each. The entries are minor versions, so setup-python tracks the newest stable patch and never a pre-release. mypy still targets python_version 3.10 and the docs/quality jobs stay on 3.12 (sphinx is capped below 8.2): the floor and the docs toolchain are what need pinning, not the ceiling. Verified locally with the project venv before committing: both workflow files parse and report the same matrix, `python -m build` succeeds (hatchling validates classifiers against the trove list, so the 3.14 entry is confirmed valid), the built wheel's METADATA carries all five version classifiers, `twine check` passes on wheel and sdist, and the tag-validation shell block accepts `v0.1.0` and `0.1.0` while rejecting `v0.2.0` -- exercising the tomli fallback for 3.10 on the way. Two things this does NOT do. It does not repeat ci.yml's build-audit assertions -- no tests/ or testing/ path in either artifact, both client trees present, skills/ in the sdist only -- so those still run on pushes and PRs rather than in the release path; duplicating them would put two copies of a publish-safety check in the repo to drift apart, and factoring them into a script both workflows call is the better fix when someone wants it. And it cannot work until the Trusted Publisher is registered on PyPI (owner `baraline`, repo `easyvista_python_client`, workflow `release.yml`, environment `pypi`), which for a project with no uploads yet means a pending publisher. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
There was no release workflow. CI tested every push and audited the built artifacts, but nothing published: the only route to PyPI was a maintainer running
twine uploadfrom a workstation, with a long-lived API token, against whatever the working tree happened to contain.docs/publishing.rstdocumented exactly that and nothing else..github/workflows/release.ymlis ported fromglpi_python_client's, which already solves this, and adapted to this package.What it does
Triggered by a published GitHub release (or
workflow_dispatch):testspytest -m "not integration" --strict-markers --strict-configqualityunasync_build.py --check,scripts/lint_hand_written_sync.py --check,sphinx -Wbuildpyproject.toml↔__version__validation,python -m build,twine check, upload artifactpublish-pypienvironment: pypi,id-token: write→ Trusted Publishingpublish-readthedocssync-versions+ build for the release version; self-skips when unconfiguredTrusted Publishing, not a stored token — PyPI mints a short-lived credential from the job's OIDC identity, so the repository holds no publishing secret. The
pypienvironment is what that publisher is scoped to, and where a required-reviewer gate on the upload belongs if we ever want one.The version gate exists because PyPI takes whatever
pyproject.tomlsays and ignores the tag. A tag that disagrees publishes a version nobody can find by the name it was announced under; a__version__that disagrees misreports at runtime. Neither is fixable after upload, only yankable.The
_sync/and twin-lint gates are re-run here rather than trusted fromci.yml, because this is the run that uploads. A stale generated sync tree builds, installs and imports cleanly — it is wrong only at the call site.ci.ymlcovers pushes tomain, so a release cut frommainis already checked; this makes the property hold for the ref actually being released.Docs follow the package:
publish-readthedocswaits onpublish-pypisucceeding on a release event, so the documented version is one that exists. Concurrency is deliberately notcancel-in-progress— a cancelled upload can leave a release half-published.workflow_dispatchruns everything except the upload, so a release can be rehearsed before it is tagged.Python 3.10–3.14
Both matrices and the classifiers now span 3.10–3.14. This was not a free line to add: the
_sync/check is a byte-equality comparison against output thattokenize-rtreconstructs from the source — which is why both it andunasyncare exact-pinned — so a tokenizer change in a new interpreter can fail it on that version alone. So it was measured:unasync_build.py --check_sync/ is up to date (8 modules)_sync/ is up to date (8 modules)Both final releases, not release candidates. Same statement count as 3.10, so no version-gated code paths appeared, and
_sync/regenerated byte-identical under each tokenizer.Matrix entries are minor versions, so
setup-pythontracks the newest stable patch and never a pre-release (that needsallow-prereleases). mypy still targetspython_version = "3.10"and the docs/quality jobs stay on 3.12 (sphinx is capped below 8.2): the floor and the docs toolchain are what need pinning, not the ceiling.Verification
Run locally with the project venv before committing:
python -m buildsucceeds — hatchling validates classifiers against the trove list, so the3.14entry is confirmed validMETADATAcarries all five version classifierstwine checkpasses on wheel and sdistv0.1.0and0.1.0and rejectsv0.2.0, exercising thetomlifallback for 3.10 on the waysphinx -W --keep-goingbuilds clean, so the newqualitygate passes on the current docsBefore the first release can publish
publish-pypifails at the upload: ownerbaraline, repoeasyvista_python_client, workflowrelease.yml, environmentpypi. With no uploads yet this is a pending publisher. Useworkflow_dispatchfirst — it runs everything except the upload.v0.1.0has no tag (the CHANGELOG claims it shipped and its release link 404s). Since0.1.0was never published, releasing the currentUnreleasedcontent as0.2.0is cleaner than back-tagging.Not in scope
This does not repeat
ci.yml'sbuild-auditassertions — notests/ortesting/path in either artifact, both client trees present,skills/in the sdist only. Those still run on pushes and PRs rather than in the release path. Duplicating them would put two copies of a publish-safety check in the repo to drift apart; factoring them into a script both workflows call is the better fix, and is a follow-up rather than part of this change.🤖 Generated with Claude Code