Skip to content

ci: publish releases to PyPI from CI, and test through Python 3.14 - #2

Merged
baraline merged 1 commit into
mainfrom
ci/pypi-release-and-py314
Aug 3, 2026
Merged

baraline merged 1 commit into
mainfrom
ci/pypi-release-and-py314

Conversation

@baraline

@baraline baraline commented Aug 3, 2026

Copy link
Copy Markdown
Owner

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 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.

.github/workflows/release.yml is ported from glpi_python_client's, which already solves this, and adapted to this package.

What it does

Triggered by a published GitHub release (or workflow_dispatch):

Job Content
tests matrix 3.10–3.14, pytest -m "not integration" --strict-markers --strict-config
quality Ruff, mypy, unasync_build.py --check, scripts/lint_hand_written_sync.py --check, sphinx -W
build tag ↔ pyproject.toml ↔ __version__ validation, python -m build, twine check, upload artifact
publish-pypi release events only; environment: pypi, id-token: write → Trusted Publishing
publish-readthedocs sync-versions + build for the release version; self-skips when unconfigured

Trusted 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 pypi environment 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.toml says 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 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. ci.yml covers pushes to main, so a release cut from main is already checked; this makes the property hold for the ref actually being released.

Docs follow the package: publish-readthedocs waits on publish-pypi succeeding on a release event, so the documented version is one that exists. Concurrency is deliberately not cancel-in-progress — a cancelled upload can leave a release half-published.

workflow_dispatch runs 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 that tokenize-rt reconstructs from the source — which is why both it and unasync are exact-pinned — so a tokenizer change in a new interpreter can fail it on that version alone. So it was measured:

3.13.14 3.14.6
unit suite 600 passed, 87 deselected 600 passed, 87 deselected
coverage 99.21% / 1270 stmts 99.21% / 1270 stmts
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-python tracks the newest stable patch and never a pre-release (that needs allow-prereleases). 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.

Verification

Run 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
  • the tag-validation shell block accepts v0.1.0 and 0.1.0 and rejects v0.2.0, exercising the tomli fallback for 3.10 on the way
  • sphinx -W --keep-going builds clean, so the new quality gate passes on the current docs

Before the first release can publish

  1. Register the PyPI Trusted Publisher or publish-pypi fails at the upload: owner baraline, repo easyvista_python_client, workflow release.yml, environment pypi. With no uploads yet this is a pending publisher. Use workflow_dispatch first — it runs everything except the upload.
  2. v0.1.0 has no tag (the CHANGELOG claims it shipped and its release link 404s). Since 0.1.0 was never published, releasing the current Unreleased content as 0.2.0 is cleaner than back-tagging.

Not in scope

This 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. 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

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>
@baraline
baraline merged commit fd36c58 into main Aug 3, 2026
6 checks passed
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