-
Notifications
You must be signed in to change notification settings - Fork 0
ReleaseProcess
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.
- Land feature work on
mainvia PRs whose title is a valid Conventional Commit (feat: …,fix: …,refactor: …, etc.). The repo squash-merges, so the PR title becomes the commit subjectsemantic-releaseanalyzes. - Optionally preview locally:
yarn release:dry-run(prints the computed version + notes without publishing). - Merge to
main. TheCIworkflow runs. - On
CIsuccess,release.ymlfires viaworkflow_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.
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-only —
semantic-releasepushes only thevX.Y.Ztag; nothing is committed back tomain(no@semantic-release/git, no committed changelog). -
Publish-before-release — the
releasejobneeds: [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'sworkflow_run.workflowsis['CI'], which must matchci.yml'sname: CIexactly. Renaming either side silently breaks releases.
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.
.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=85 — fails, 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.pyverify_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 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.
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.
-
Ensure the sibling JavaScript SDK is checked out at
../javascript-sdk. -
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. -
Inspect the diff to confirm changes are intentional:
git diff tests/parity/fixtures/
-
Run the parity suite to confirm the Python SDK still matches:
uv run pytest tests/parity -x
-
If a parity-critical field diverged, fix the Python implementation (never the fixture) until the suite is green.
-
Open a PR with a
fix:/feat:title describing the JS-contract change. CI re-runs the parity gate on the PR.
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.
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).
Preview the computed version and rendered notes without publishing, tagging, or creating a Release:
corepack enable
yarn install --immutable
yarn release:dry-runRun 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/).
There is no manual step beyond merging:
- Open a PR whose title is a valid Conventional Commit. For a releasable change use
feat:(minor),fix:(patch), or include aBREAKING CHANGE:footer /!marker (major). - Get CI green and squash-merge to
main. The PR title becomes the commit subject. - On the resulting
CIsuccess,release.ymlfires automatically: compute version → build → publish to PyPI (OIDC) → push tag + create GitHub Release. - Verify the artifact on PyPI and the GitHub Releases page.
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.
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).
-
Sign in to https://pypi.org as an owner of the
convert-python-sdkproject (or configure a pending publisher for a brand-new project). -
Go to Settings → Publishing → Add a new publisher (GitHub Actions).
-
Enter these values exactly:
Field Value Owner convertcomRepository python-sdkWorkflow filename release.ymlEnvironment name pypi -
Save. The
publish-pypijob'senvironment: pypiandid-token: writepermission 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.
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 (...).
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 → Manage → Releases → the version → Options → Yank) or the PyPI API. Yanking leaves the file available for pinned installs but excludes it from new resolutions.
| 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 |
- Testing — pytest layout, parity fixtures, mypy, lint, and coverage commands
Copyrights © 2025 All Rights Reserved by Convert Insights, Inc.
Getting Started
Python SDK
- Quickstart
- Installation
- Initialization
- Configuration
- Code Examples
- Type Hints
- Diagnostics
- Extending
- Testing
- Async & Frameworks
Migration
Core Concepts
- Experiences & Variations
- Feature Flags
- Bucketing Algorithm
- Rule Evaluation
- Segments
- Data Management
- Event System
- API Communication
How-To Guides
- Running Experiences
- Running Features
- Tracking Conversions
- Visitor Context
- Persistent DataStore
- Troubleshooting
Edge & Integrations
Maintainers