Skip to content

ReleaseProcess

Ahmed Abbas edited this page Jun 18, 2026 · 4 revisions

Release Process

Maintainer-facing reference for cutting a convert-python-sdk release. The release pipeline is commit-driven and zero-touch (mirroring the ratified Convert SDK release standard set by ruby-sdk): merge a PR with a Conventional-Commit title to main, and once the CI workflow succeeds, semantic-release computes the next version from the commit history, builds the distributions carrying that version, publishes to PyPI via OIDC Trusted Publishing, and — only after a successful upload — pushes the vX.Y.Z tag and creates a GitHub Release whose notes are the changelog. There is no manual version bump, no committed CHANGELOG.md, and no long-lived PyPI token stored anywhere. No commit is ever pushed to main by the release.

Distribution name on PyPI: convert-python-sdk
Import package: convert_sdk
Version: computed by semantic-release and stamped into src/convert_sdk/version.py at build time (uncommitted). The committed value is the 0.0.0 dev placeholder.

The authoritative in-repo doc is RELEASE.md at the repo root; this wiki page mirrors it.

At a glance

  1. Land feature work on main via PRs whose title is a valid Conventional Commit (feat: …, fix: …, refactor: …, etc.). The repo squash-merges, so the PR title becomes the commit subject semantic-release analyzes.
  2. Optionally preview locally: yarn release:dry-run (prints the computed version + notes without publishing).
  3. Merge to main. The CI workflow runs.
  4. On CI success, release.yml fires via workflow_run: it computes the version, builds, publishes to PyPI (OIDC), then tags + creates the GitHub Release.

There is no manual version bump and no tag push by a human.


Release chain overview

PR (Conventional-Commit title) ──squash-merge──▶ main
        │
        ▼
   CI workflow ("CI")  ── on success (push event) ──▶  Release workflow (workflow_run)
                                                            │
   ┌────────────────────────────────────────────────────────┘
   ▼
 prepare       : semantic-release --dry-run computes the next version + notes;
                 stamps version.py (uncommitted); `uv build` → wheel + sdist artifact
   │
   ▼
 publish-pypi  : pypa/gh-action-pypi-publish uploads to PyPI via OIDC (environment: pypi)
   │            (publish-before-release — if this fails, the release job is skipped)
   ▼
 release       : @semantic-release/github pushes the vX.Y.Z tag + creates the GitHub Release
                 (notes = generated changelog). Runs LAST, after a successful upload.

Key guarantees:

  • Tag-onlysemantic-release pushes only the vX.Y.Z tag; nothing is committed back to main (no @semantic-release/git, no committed changelog).
  • Publish-before-release — the release job needs: [prepare, publish-pypi], so a failed PyPI upload skips tagging and the Release; the next qualifying merge retries the same version.
  • Fork-PR safeguard — the release jobs are guarded by github.event.workflow_run.event == 'push', so a fork PR's CI run can never trigger a release. Do not remove this guard.
  • Workflow-name coupling (load-bearing)release.yml's workflow_run.workflows is ['CI'], which must match ci.yml's name: CI exactly. Renaming either side silently breaks releases.

Versioning & Conventional-Commit map

Version impact is decided by @semantic-release/commit-analyzer (conventionalcommits preset). Only feat:, fix:, and BREAKING CHANGE bump the version; everything else is a no-release (though some types are still shown in the notes when a release is triggered by one of those three). Tag format is v${version}.

Commit type Release impact In release notes
fix: patch Yes (Bug Fixes)
feat: minor Yes (Features)
refactor: no release Yes (Refactoring) — shown only
BREAKING CHANGE: footer / ! marker major Yes
chore:, docs:, ci:, test:, style:, perf: no release No (hidden)

A merge whose commits are all no-release types runs the workflow, determines no release is due, and publishes nothing — that is expected and not an error.


CI quality gates

.github/workflows/ci.yml (workflow name: CI) runs on every PR and every push to main. The CI workflow's success on main is what triggers the release via workflow_run — all jobs must pass before anything is published.

Gate Tool Threshold / rule
PR title pr-title (Conventional Commits grep, PRs only) PR title not matching ^(feat|fix|docs|test|refactor|perf|build|ci|chore)(\(.+\))?!?: .+ fails CI
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)
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 the local gates in one shot:

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

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 (Ruff, mypy, pytest + coverage floors, evaluation coverage, parity, uv build).


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. Open a PR with a fix:/feat: title describing the JS-contract change. 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, and open a PR with a chore:/build: title. The bounds-check job validates both edges.


Changelog

There is no committed changelog file and no towncrier. The changelog is generated at release time by @semantic-release/release-notes-generator from the Conventional-Commit history since the previous tag, and published as the GitHub Release notes. The Changelog project URL on PyPI points at https://github.com/convertcom/python-sdk/releases.

To preview what the next release would contain, run a dry run (see below).


Dry run

Preview the computed version and rendered notes without publishing, tagging, or creating a Release:

corepack enable
yarn install --immutable
yarn release:dry-run

Run off main (e.g. on a feature branch), semantic-release prints a message like "This test run was triggered on the branch <branch>, while semantic-release is configured to only publish from main, therefore a new version won't be published." — that message is expected off main and is not a failure.

If you see Cannot find module '<preset>', confirm .yarnrc.yml contains nodeLinker: node-modules (the dynamic preset import must be able to walk node_modules/).


Triggering a release

There is no manual step beyond merging:

  1. Open a PR whose title is a valid Conventional Commit. For a releasable change use feat: (minor), fix: (patch), or include a BREAKING CHANGE: footer / ! marker (major).
  2. Get CI green and squash-merge to main. The PR title becomes the commit subject.
  3. On the resulting CI success, release.yml fires automatically: compute version → build → publish to PyPI (OIDC) → push tag + create GitHub Release.
  4. Verify the artifact on PyPI and the GitHub Releases page.

First release

The first release is produced automatically. With no prior v* tag, semantic-release emits v1.0.0 (its fixed first-release default — same as ruby-sdk). The committed version.py ships 0.0.0; the build stamps the real version. Never hand-create tags. If a 0.x series is desired instead, a maintainer pre-creates the initial tag before the first qualifying merge; absent that, the first release is 1.0.0.


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

The pypi GitHub Environment must have no required reviewers and no wait timer. The entire publish-pypi job runs in that environment for the OIDC subject claim; a required reviewer or wait timer would block the publish (and therefore the release) indefinitely.

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.

Branch protection — required checks

Configure the main branch protection to require these checks (names must match the job names): PR title (Conventional Commits), Ruff lint, mypy --strict, the 15-cell test (...) matrix, bounds-check (lower) / bounds-check (upper), and build (...).


Rollback

PyPI does not allow replacing or overwriting an already-published version. To recover from a bad release:

  • Ship a forward fix. Merge a fix: PR; the pipeline publishes the next patch version.
  • Yank the bad version on PyPI if it must not be installed by new resolutions. PyPI has no public CLI equivalent of gem yank — yank via the PyPI project web UI (project → ManageReleases → the version → OptionsYank) or the PyPI API. Yanking leaves the file available for pinned installs but excludes it from new resolutions.

Troubleshooting

Symptom Likely cause Fix
Release workflow didn't run CI didn't succeed on main, or the run wasn't a push event Check the CI run on main; the release only fires on a successful push-triggered CI run
Release ran but published nothing Only no-release commit types (chore/docs/ci/test/style/perf/refactor) since the last tag Expected — merge a feat:/fix:/BREAKING change to cut a release
OIDC 403 on publish Trusted Publisher entry misconfigured Verify owner / repo / workflow / environment on pypi.org match exactly
Publish job hangs / never starts pypi Environment has a required reviewer or wait timer Remove all required reviewers and wait timers from the pypi environment
GitHub Release / tag missing after a successful publish The release job failed (e.g. prepareCmd stamp) Re-check the release job logs; the version is already on PyPI, so ship a forward fix: to advance
yarn release:dry-run says "not triggered in a known release branch" Running off main Expected off main — this is informational, not an error
Cannot find module '<preset>' .yarnrc.yml linker wrong Ensure .yarnrc.yml is nodeLinker: node-modules
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

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