Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
# Keep in step with the `Programming Language :: Python` classifiers in
# pyproject.toml and with the same matrix in release.yml.
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
Expand Down
264 changes: 264 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
name: Release

on:
release:
types:
- published
workflow_dispatch:

permissions:
contents: read

concurrency:
# Not cancel-in-progress: a run that has reached publish-pypi must be left
# alone. A cancelled upload can leave a release half-published on PyPI, and
# a version already on PyPI cannot be re-uploaded.
group: release-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false

jobs:
tests:
name: Release tests (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
# The whole matrix reports, so a release blocked on one interpreter shows
# whether the others are healthy before anyone starts bisecting.
fail-fast: false
matrix:
# Mirrors ci.yml and the `Programming Language :: Python` classifiers in
# pyproject.toml. requires-python is >=3.10; the upper end is bounded by
# what the suite is verified on, not by anything in the code. The
# generated-_sync/ gate is the reason a new interpreter is not just
# added on faith: it is a byte-equality check whose output comes from
# tokenize-rt re-tokenizing the source, so a tokenizer change can fail
# it on one version alone. The suite and that gate were both run on
# 3.13.14 and 3.14.6 before either was listed here (600 passed, same
# coverage as 3.10, _sync/ regenerated identical under both).
# These entries are minor versions, not pinned patches: setup-python
# resolves a bare "3.14" to the newest STABLE 3.14.x and never to a
# pre-release, which needs allow-prereleases.
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip

- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -e .[dev]

# `integration_tests/` calls a real EasyVista instance using credentials
# the user supplies; it can never run here. Excluded by marker rather
# than left to skip on absent credentials, so a leaked secret could not
# make a release run go live. --strict-markers/--strict-config must be
# passed here, not in addopts, where pytest 9 silently ignores them.
# Coverage runs implicitly: --cov lives in pyproject's addopts and
# [tool.coverage.report] fail_under = 95 gates it.
- name: Run tests
run: python -m pytest -m "not integration" --strict-markers --strict-config

quality:
name: Release quality checks
runs-on: ubuntu-latest

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip

- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -e .[dev]

- name: Run Ruff
run: python -m ruff check .

- name: Run mypy
run: python -m mypy easyvista_python_client

# The sync client tree is generated from the async one and checked in, so
# a stale _sync/ is a shippable artifact: it builds, imports, and is only
# wrong at the call site. Re-checked here rather than trusted from ci.yml
# because this is the run that uploads to PyPI, and a version published
# with a stale sync client cannot be replaced -- only yanked.
- name: Check the generated sync tree is up to date
run: python unasync_build.py --check

# `ruff check .` above skips easyvista_python_client/_sync/ wholesale, so
# the two HAND_WRITTEN twins living in that tree are linted by nothing
# else. --check makes this report rather than reformat.
- name: Lint the hand-written modules inside the generated tree
run: python scripts/lint_hand_written_sync.py --check

# Read the Docs builds with fail_on_warning (.readthedocs.yaml), so a
# docs warning breaks the published documentation for a release that is
# otherwise fine. -W here turns that into a pre-publish failure instead.
- name: Build documentation
run: python -m sphinx -W --keep-going -b html docs docs/_build/html

build:
name: Build distributions
needs:
- tests
- quality
runs-on: ubuntu-latest

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip

- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -e .[dev]

# The version lives in two places -- pyproject.toml and
# easyvista_python_client.__version__ -- and the git tag is a third. PyPI
# takes whatever pyproject says, so a tag that disagrees publishes a
# release nobody can find by version, and a __version__ that disagrees
# misreports at runtime. Both are unfixable after upload. Tags in this
# repo are v-prefixed (see the CHANGELOG compare links), so the leading v
# is stripped before comparing.
- name: Validate release tag matches package version
if: github.event_name == 'release'
shell: bash
run: |
set -euo pipefail

release_tag="${{ github.event.release.tag_name }}"
normalized_tag="${release_tag#v}"
pyproject_version="$(python - <<'PY'
from pathlib import Path
try:
from tomllib import loads as toml_loads
except ModuleNotFoundError:
from tomli import loads as toml_loads

pyproject = toml_loads(Path('pyproject.toml').read_text(encoding='utf-8'))
print(pyproject['project']['version'])
PY
)"
package_version="$(python - <<'PY'
import easyvista_python_client
print(easyvista_python_client.__version__)
PY
)"

if [ "${pyproject_version}" != "${package_version}" ]; then
echo "pyproject.toml version (${pyproject_version}) does not match easyvista_python_client.__version__ (${package_version})." >&2
exit 1
fi

if [ "${normalized_tag}" != "${pyproject_version}" ]; then
echo "GitHub release tag (${release_tag}) does not match package version (${pyproject_version})." >&2
exit 1
fi

- name: Build distributions
run: python -m build

- name: Check distribution metadata
run: python -m twine check dist/*

# publish-pypi uploads exactly these files. Building there would make the
# audited artifact and the uploaded one two different builds.
- name: Upload distributions
uses: actions/upload-artifact@v4
with:
name: easyvista-python-client-dist
path: dist/*

publish-pypi:
name: Upload release to PyPI
# workflow_dispatch runs everything above as a rehearsal and stops here:
# only a published GitHub release uploads.
if: github.event_name == 'release'
needs:
- build
runs-on: ubuntu-latest
# The environment is what a PyPI Trusted Publisher is scoped to, and it is
# where a required-reviewer gate would be attached.
environment:
name: pypi
url: https://pypi.org/project/easyvista-python-client/
permissions:
# IMPORTANT: this permission is mandatory for Trusted Publishing. There
# is no PyPI API token in this repository's secrets -- PyPI mints a
# short-lived credential from this job's OIDC identity instead.
id-token: write

steps:
- name: Download distributions
uses: actions/download-artifact@v4
with:
name: easyvista-python-client-dist
path: dist

- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist/

publish-readthedocs:
name: Publish release docs
# Docs follow the package: on a release, only after PyPI accepted the
# upload, so the documented version is one that exists. On
# workflow_dispatch there is no upload to wait for, hence the
# event_name branch -- and `always()` because a skipped publish-pypi would
# otherwise skip this job with it.
if: ${{ always() && needs.build.result == 'success' && (github.event_name != 'release' || needs.publish-pypi.result == 'success') }}
needs:
- build
- publish-pypi
runs-on: ubuntu-latest
env:
READTHEDOCS_BASE_URL: ${{ vars.READTHEDOCS_BASE_URL }}
READTHEDOCS_PROJECT: ${{ secrets.READTHEDOCS_PROJECT }}
READTHEDOCS_TOKEN: ${{ secrets.READTHEDOCS_TOKEN }}
READTHEDOCS_VERSION: ${{ vars.READTHEDOCS_RELEASE_VERSION }}

steps:
# Read the Docs is optional: with no token configured this exits 0 rather
# than failing a release whose package already published successfully.
- name: Sync versions and trigger Read the Docs release build
shell: bash
run: |
set -euo pipefail
if [ -z "${READTHEDOCS_TOKEN:-}" ] || [ -z "${READTHEDOCS_PROJECT:-}" ]; then
echo "Read the Docs secrets are not configured; skipping release build."
exit 0
fi

base_url="${READTHEDOCS_BASE_URL:-https://app.readthedocs.org}"
version="${READTHEDOCS_VERSION:-stable}"

# sync-versions first: the tag this release created is not a Read the
# Docs version until RTD has seen it.
curl --fail --silent --show-error --location \
--request POST \
--header "Authorization: Token ${READTHEDOCS_TOKEN}" \
"${base_url}/api/v3/projects/${READTHEDOCS_PROJECT}/sync-versions/"

curl --fail --silent --show-error --location \
--request POST \
--header "Authorization: Token ${READTHEDOCS_TOKEN}" \
"${base_url}/api/v3/projects/${READTHEDOCS_PROJECT}/versions/${version}/builds/"
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ a deprecation policy will follow the 1.0 release.

### Added

- Python 3.13 and 3.14 are now tested and declared supported (classifiers, and
the CI/release matrices, now span 3.10--3.14). No code changed: the suite
passes unmodified on both, with the same statement count and coverage as on
3.10, and the generated `_sync/` tree regenerates byte-identically under each
interpreter's tokenizer.
- `.github/workflows/release.yml`: releases are built and published to PyPI by
CI when a GitHub release is published, using Trusted Publishing (no API token
in the repository). The workflow re-runs the test matrix and the quality gates,
refuses to build when the release tag, `pyproject.toml` and
`easyvista_python_client.__version__` disagree, runs `twine check`, and then
triggers a Read the Docs build for the release. `workflow_dispatch` rehearses
everything except the upload. See `docs/publishing.rst`.
- `skills/`: eight Agent Skills covering client setup, search syntax, tickets,
ticket actions, documents, assets, the directory and reporting/context, with
an index in `skills/README.md`. Shipped in the source distribution, not in
Expand Down
59 changes: 50 additions & 9 deletions docs/publishing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,63 @@ Publishing

The project builds with ``hatchling`` and publishes to PyPI as ``easyvista-python-client``.

Build the distribution
-----------------------
Releases are published by ``.github/workflows/release.yml``, which runs when a GitHub
release is *published*. No maintainer uploads by hand, and no PyPI API token is stored
in the repository: the workflow authenticates to PyPI with `Trusted Publishing
<https://docs.pypi.org/trusted-publishers/>`_, which mints a short-lived credential from
the workflow's OIDC identity.

.. code-block:: bash
Cutting a release
-----------------

python -m build
#. Bump the version in **both** places -- ``pyproject.toml`` (``project.version``) and
``easyvista_python_client.__version__``. The release workflow refuses to build if they
disagree, or if they disagree with the tag.
#. Move the ``CHANGELOG.md`` ``[Unreleased]`` entries under the new version and update the
compare links at the bottom of the file.
#. Merge to ``main`` and let CI go green.
#. Publish a GitHub release whose tag is the version, ``v``-prefixed --
``v0.2.0`` for version ``0.2.0``. (The workflow strips a leading ``v`` before comparing,
so an unprefixed tag also passes; the repository's existing tags are prefixed.)

The workflow then runs the test matrix (3.10--3.14) and the quality gates -- Ruff, mypy,
the generated-``_sync``-tree check, the hand-written-twin lint and a warnings-as-errors
Sphinx build -- validates the tag against the package version, builds the wheel and the
sdist, runs ``twine check``, uploads to PyPI, and finally triggers a Read the Docs build
for the release version.

Rehearsing without publishing
-----------------------------

This writes a wheel and an sdist to ``dist/``.
Running the workflow manually (``workflow_dispatch``) exercises everything except the
upload: ``publish-pypi`` is gated on the event being a release. Use it to check that a
release *would* build before tagging.

Check and upload
One-time setup
--------------

Trusted Publishing
Configure a publisher on PyPI for the project pointing at owner ``baraline``,
repository ``easyvista_python_client``, workflow ``release.yml``, environment ``pypi``.
Until the project's first upload exists, this is registered as a *pending* publisher.
The ``pypi`` GitHub environment is also where a required-reviewer gate on the upload
step belongs, if the project wants one.

Read the Docs (optional)
The docs job self-skips when unconfigured. To enable it, set the ``READTHEDOCS_PROJECT``
and ``READTHEDOCS_TOKEN`` secrets, and optionally the ``READTHEDOCS_BASE_URL`` and
``READTHEDOCS_RELEASE_VERSION`` variables (defaults: ``https://app.readthedocs.org``
and ``stable``).

Building locally
----------------

.. code-block:: bash

python -m build
twine check dist/*
twine upload dist/*

Bump the version in ``pyproject.toml`` (and ``easyvista_python_client.__version__``) before building
a release.
This writes a wheel and an sdist to ``dist/`` and validates their metadata -- the same two
steps the release workflow runs. Uploading from a workstation with ``twine upload`` still
works, but it bypasses every gate above and is not how releases are cut; prefer fixing the
workflow over working around it.
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ classifiers = [
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Software Development :: Libraries :: Python Modules",
"Typing :: Typed",
Expand Down
Loading