Skip to content

ReleaseProcess

Usman Abbas edited this page Jun 8, 2026 · 4 revisions

Release Process

Maintainer-facing reference for cutting a convert-python-sdk release. The release pipeline is fully tag-driven: pushing a v<X.Y.Z> tag to main triggers the GitHub Actions workflow that re-runs the full CI gate, compiles the changelog, builds the distributions, publishes to PyPI via OIDC Trusted Publishing, and creates a GitHub Release. No long-lived PyPI tokens are stored anywhere.

Distribution name on PyPI: convert-python-sdk
Import package: convert_sdk
Version single-sourced from: src/convert_sdk/version.py

At a glance

  1. Land all feature work on main — each PR must carry a changes/ changelog fragment.
  2. Bump __version__ in src/convert_sdk/version.py.
  3. Validate all gates locally: uv run python scripts/verify_release.py --version X.Y.Z.
  4. Open the version-bump PR, get CI green, and merge to main.
  5. Tag the merge commit vX.Y.Z and push the tag.
  6. The release workflow publishes to PyPI (OIDC) and creates a GitHub Release.

CI quality gates

.github/workflows/ci.yml runs on every PR and every push to main. The release workflow reuses ci.yml verbatim as the release gate — all jobs must pass before anything is published.

Gate Tool Threshold / rule
Lint Ruff (E/W/F/B/SIM/RUF, line-length 100) Any finding on src/, tests/, scripts/ blocks merge
Type-check mypy --strict on src/convert_sdk Any error blocks merge
Tests pytest, 15-cell matrix (Python 3.9–3.13 × ubuntu / macos / windows) Any failing cell blocks merge
Coverage (project) pytest-cov --cov-fail-under=85fails, not warns
Coverage (evaluation) coverage report evaluation/ modules ≥ 95% — fails, not warns
Parity pytest tests/parity/ 100% pass — release-blocking (see below)
Dependency bounds uv lower + upper Both edges must pass (see below)
Changelog fragment towncrier check (PRs only) PR touching the package without a fragment fails CI
Build uv build Wheel + sdist must build cleanly

Note: pytest is pinned to 8.4.x in pyproject.toml because pytest 9 drops Python 3.9 support, which is the lower edge of the CI matrix.

Reproduce all gates locally in one shot:

uv sync --group dev
uv run python scripts/verify_release.py --version 0.1.0

# Skip the slow build step while iterating:
uv run python scripts/verify_release.py --version 0.1.0 --skip-build

verify_release.py exits non-zero if any gate fails and prints a per-gate PASS/FAIL summary. It is the maintainer's local mirror of CI.


Coverage and parity gates

Coverage must fail, never warn. The project floor is 85% across src/convert_sdk/. The evaluation core (evaluation/) carries a stricter 95% floor because it contains the cross-SDK-critical bucketing, rule matching, and feature resolution engine. Both floors are enforced independently — passing the project floor while failing the evaluation floor still blocks the release.

Do not add # pragma: no cover to evaluation or parity code without an explicit, reviewed justification.

Parity is release-blocking. tests/parity/ runs the Python SDK's real evaluation surfaces against committed JavaScript-reference golden vectors (Story 3.5 infrastructure). The suite runs JS-runtime-free against the committed tests/parity/fixtures/*.json — no Node.js required at CI time. A divergence on a parity-critical field (reason, environment, bucket_value, variation_key, the hashed visitor reference) is a release blocker, not an advisory.


Updating parity fixtures when JavaScript contracts change

Parity fixtures are checked in so CI does not require a Node.js runtime. Regeneration is a maintainer action when the JavaScript SDK's bucketing or rule-matching behavior changes.

  1. Ensure the sibling JavaScript SDK is checked out at ../javascript-sdk.

  2. Regenerate the golden vectors from the JS reference implementations under scripts/js_reference/:

    uv run python scripts/generate_parity_fixtures.py

    This refreshes tests/parity/fixtures/*.json.

  3. Inspect the diff to confirm changes are intentional:

    git diff tests/parity/fixtures/
  4. Run the parity suite to confirm the Python SDK still matches:

    uv run pytest tests/parity -x
  5. If a parity-critical field diverged, fix the Python implementation (never the fixture) until the suite is green.

  6. Add a changes/ fragment recording the JS SDK commit the vectors came from, and open a PR. CI re-runs the parity gate on the PR.


Dependency bounds policy

httpx is the SDK's only runtime dependency. The bucketing layer ships a pure-Python MurmurHash3-32 implementation — there is no mmh3 or other hashing dependency.

pyproject.toml declares a compatible-release range (httpx>=0.28,<1.0). Exact lower-bound pins live only in ci/lower-bounds-overrides.txt. Never put exact pins in pyproject.toml.

The bounds-check CI job verifies both edges:

  • Lower bound — installs httpx==0.28.0 (from the override file) on Python 3.9 and runs the unit + integration suite. This is where age-related breakage surfaces first.
  • Upper bound — resolves the newest compatible versions on Python 3.13 and runs the same suite.

Widening the bounds is a deliberate maintainer action, not an opportunistic one. When a new httpx version needs support: confirm the upstream changelog for breaking changes, update the range in pyproject.toml and the pin in ci/lower-bounds-overrides.txt, add a changes/ fragment, and let the bounds-check job validate both edges.


Changelog discipline (towncrier)

The changelog is compiled by towncrier from news fragments under changes/. Never hand-edit CHANGELOG.md.

Every PR with user-visible impact must add a fragment. This project uses PR-number-less orphan fragments named +story-{N}-{slug}.{type}.md (e.g. +story-5-2-auto-config-refresh.feature.md). Fragment types:

Type Use for
feature New user-facing capability
bugfix Bug fix
breaking Breaking change
deprecation Deprecation notice
internal CI, refactoring, dependency bumps, test-only changes

Internal-only PRs (refactors, CI changes, dependency bumps without behavior changes) should still add a changes/+…internal.md fragment to keep the missing-fragment CI gate green.

Fragments are compiled into CHANGELOG.md only at release time by the release workflow — never on a feature branch, as that causes merge conflicts.

Preview the unreleased changelog without consuming fragments:

uv run towncrier build --draft --version X.Y.Z

Cutting a release

Step-by-step

1. Bump the version.

Edit src/convert_sdk/version.py:

__version__ = "0.2.0"

This is the single source of truth. pyproject.toml declares dynamic = ["version"] and reads from this file via hatchling.

2. Validate locally.

uv sync --group dev
uv run python scripts/verify_release.py --version 0.2.0

All gates must pass before opening a PR.

3. Open the version-bump PR, get CI green, and merge to main.

4. Tag and push.

git checkout main
git pull
git tag v0.2.0
git push origin v0.2.0

The v* tag push is the only trigger for the release workflow.

5. The release workflow (.github/workflows/release.yml) then:

  • reuses ci.yml as the full gate (lint, type-check, 15-cell matrix, bounds-check, parity, build),
  • verifies the tag matches __version__ in version.py,
  • compiles the changelog with towncrier build --yes --version X.Y.Z,
  • extracts the current-version section with scripts/extract_release_notes.py,
  • builds the wheel + sdist with uv build,
  • publishes to PyPI via OIDC Trusted Publishing,
  • creates a GitHub Release with the compiled changelog and attached distributions.

6. Verify the artifact on PyPI and the GitHub Releases page.

Pre-release tags

Tags matching vX.Y.Z(a|b|rc|dev|alpha|beta)N (e.g. v0.2.0rc1) publish to PyPI and are marked as prereleases on the GitHub Release. Use these to validate the full publish flow before a stable release.


One-time PyPI Trusted Publisher setup

PyPI publishing uses OIDC Trusted Publishing. There are no long-lived PyPI tokens in repository secrets. This is a one-time manual action performed on pypi.org before the first release (or when setting up a new project owner).

  1. Sign in to https://pypi.org as an owner of the convert-python-sdk project (or configure a pending publisher for a brand-new project).

  2. Go to Settings → Publishing → Add a new publisher (GitHub Actions).

  3. Enter these values exactly:

    Field Value
    Owner convertcom
    Repository python-sdk
    Workflow filename release.yml
    Environment name pypi
  4. Save. The publish-pypi job's environment: pypi and id-token: write permission satisfy the OIDC exchange on the next v* tag.

If the publisher entry is misconfigured, the publish step fails with an OIDC 403 error — re-check all four fields above against the live repository values.


Troubleshooting

Symptom Likely cause Fix
tag (X) does not match package version (Y) Forgot to bump version.py Bump __version__, delete the incorrect tag, re-tag
OIDC 403 on publish Trusted Publisher entry misconfigured Verify owner / repo / workflow / environment on pypi.org
towncrier check fails on PR Missing changes/ fragment Add a +…{type}.md fragment
Project coverage < 85% New code without tests Add tests; never lower the floor
evaluation/ coverage < 95% New evaluation code without tests Add tests to tests/ covering the new paths
Parity gate fails JS contract drift on a critical field Fix the Python implementation, never the fixture; see parity workflow above
bounds-check (lower) fails Code relies on an httpx feature not in 0.28.0 Fix the code or deliberately widen the lower bound with a fragment

What to read next

  • Testing — pytest layout, parity fixtures, mypy, lint, and coverage commands
  • Roadmap — what is shipped, what is planned, and when

Clone this wiki locally