Releasing

Releases go to PyPI as the ecg-records distribution. A released version can never be replaced or re-uploaded: a defect is fixed forward in a new version. Every step below exists to catch problems before that point.

1. Prepare the version

  1. Choose the version with Semantic Versioning. A schema minor needs at least a package minor.
  2. Set the version in all three places, which must agree: version in feature_extraction/pyproject.toml, __version__ in feature_extraction/ecgfeat/__init__.py and LIBRARY_VERSION in feature_extraction/ecgfeat/pipeline/record_builder.py (written into every record's provenance and record_id).
  3. Move the ## [Unreleased] entries in feature_extraction/CHANGELOG.md into a ## [X.Y.Z] - YYYY-MM-DD section. tools/check_changelog.py requires it.
  4. If published values changed on purpose, freeze a new golden baseline (Golden corpus) and point CI at it.

1b. The engine distribution

Since the engine split there are two PyPI packages: the pure-Python ecg-records (this section's steps) and the compiled ecg-records-engine (tools/build_engine.py). They are compatible within a minor version: the shell's engine extra requires ecg-records-engine>=<MINIMUM_ENGINE_VERSION>,<X.(Y+1) (ecgfeat/_version.py), the engine requires ecg-records>=<MINIMUM_LIBRARY_VERSION>,<X.(Y+1) (ecgfeat/_engine/__init__.py, written into the wheel by tools/build_engine.py), and each side refuses the other at run time when it is outside that range (EngineVersionError, codes engine_version_mismatch and engine_too_old). Raise the floors when one side starts needing the other's newer modules, as 0.3.2 did (the staged pipeline moved into the engine). Platforms without a wheel of the new engine then resolve ecg-records[engine] to the previous pair; a bare pip install -U ecg-records next to an old engine gets the engine_too_old message naming both ways out.

  • New minor version (X.Y.0): release both. Upload the engine first, then the shell (the shell's release smoke test installs the engine from PyPI).
  • Shell-only patch (X.Y.Z, engine unchanged): release only ecg-records; users keep the newest engine of the same minor version (the floors stay).
  • Engine patch: release only ecg-records-engine X.Y.Z; it works with every shell from its MINIMUM_LIBRARY_VERSION on.
  • A floor was raised (0.3.2): release both, engine first. A partial platform set is safe (pip falls back to the previous pair), but upload the remaining platforms soon: until then those users stay on the previous pair.

  • The engine is built by tools/build_engine.py: it stages feature_extraction/ecgfeat/_engine plus feature_extraction/engine/ (Cython setup.py, the two licence texts, the PyPI README, the pyproject.toml.in template) into build/engine-src and compiles every module with Cython. tools/check_engine_wheel.py rejects a wheel that carries engine Python source. Locally (one platform, one interpreter):

bash python tools/build_engine.py --check # dist/engine/ecg_records_engine-<version>-cp3XY-...whl

  • CI builds the full matrix (CPython 3.10–3.14 × Linux x86_64/aarch64, macOS x86_64/arm64, Windows x86_64) with cibuildwheel and checks that the compiled engine reproduces the golden record byte for byte (tools/check_engine_parity.py). Pull requests build only Linux x86_64 / CPython 3.12 (a quick gate); pushes to main and manual runs build the full matrix. Run Actions → engine-wheels → Run workflow with the version and publish ticked to upload the wheels to PyPI once every platform built and the parity check passed. The trusted publisher for the ecg-records-engine project must name workflow engine-wheels.yml.
  • On a minor version change the bump list (step 1) has a fourth place: the engine and all extras in feature_extraction/pyproject.toml (ecg-records-engine>=X.Y.0,<X.(Y+1)); tests/test_engine_distribution.py fails until they match __version__. The engine's own requirement on ecg-records is generated by tools/build_engine.py.
  • Licence keys: tools/issue_license.py issue --licensee ... --expires ... signs a key with the private signing key, which lives outside every repository (ECG_RECORDS_SIGNING_KEY), passphrase-encrypted (tools/issue_license.py protect-key --in key.pem --out key.enc.pem; the tool then asks for the passphrase, or reads ECG_RECORDS_SIGNING_KEY_PASSPHRASE). Rotating the key means replacing PUBLIC_KEY_HEX in ecgfeat/_engine/_license.py and releasing a new engine.
  • The source repository is private; the public repository humansys-lab/ecg-records carries only the documentation, schemas, changelog and examples (docs/, schemas/, examples/). After a release, first refresh docs/site/changelog.md from feature_extraction/CHANGELOG.md, then copy docs/site → docs, schemas/, feature_extraction/CHANGELOG.md and the updated example records there and push; its docs workflow deploys the site.

2. Pass the gates

On a clean checkout of the release commit:

python -m pytest -q tests
lint-imports --config feature_extraction/.importlinter
python tools/check_validation_registry.py
python tools/check_changelog.py
python tools/check_performance.py reference --repeats 5
python tools/build_docs.py --check && python tools/check_doc_examples.py && mkdocs build --strict
python snapshot_regression.py --data-root /data check --tier full \
    --baseline-id ecg-records-<baseline> --mode record-bytes     # on the dataset runner

3. Build and inspect the artifacts

Build from a clean checkout (not a working tree with local files), so that nothing untracked can leak into the sdist:

git worktree add /tmp/ecg-release <release-commit>
python -m build --outdir dist /tmp/ecg-release/feature_extraction          # shell: wheel + sdist
(cd /tmp/ecg-release && python tools/build_engine.py --check --outdir "$OLDPWD/dist")   # engine wheel for this platform; CI builds the rest
python tools/check_artifacts.py dist            # required files, no tests/data/secrets, LICENSE, disclaimer
twine check --strict dist/*
(cd dist && sha256sum *.whl *.tar.gz > SHA256SUMS)

Then smoke-test clean installs outside the repository, on the oldest and newest supported Python:

python3.10 -m venv /tmp/rc310 && /tmp/rc310/bin/pip install "numpy==1.26.*" "scipy==1.11.*" "matplotlib==3.7.*" "$(ls dist/*.whl)[viz]"
(cd /tmp && /tmp/rc310/bin/python "$OLDPWD/tools/release_smoke.py" --version X.Y.Z --with-viz)

release_smoke.py checks the version, the packaged schema resources, a canonical round trip and one extraction of a synthetic 12-lead signal. With --with-viz it also draws a plot.

4. Publish

.github/workflows/release.yml builds once, inspects, smoke-tests clean installs (with the newest engine of the same minor version from PyPI) on Python 3.10 and 3.13, and, if you ticked upload_to_pypi when starting the run, uploads the same bytes to PyPI. Run it once without the checkbox as a dry run if in doubt. Finally it verifies the PyPI install, tags ecg-records-vX.Y.Z and creates a GitHub release. It uses PyPI trusted publishing (OIDC), so no API tokens are stored.

One-time setup:

  1. The GitHub environment pypi exists and accepts deployments from main only (required reviewers are not available for private repositories on the organisation's plan; the maintainer's decision is the upload_to_pypi / publish checkbox when starting a run).
  2. On PyPI, trusted publishers (owner humansys-lab, repository ECG-feature-extraction-, environment pypi): project ecg-records with workflow release.yml, and project ecg-records-engine with workflow engine-wheels.yml. TestPyPI is not used.

Then run Actions → release → Run workflow with the version.

Alternative: manual upload with twine

With a project-scoped API token in ~/.pypirc (section pypi, username __token__), after the engine wheels are on PyPI:

python3 -m venv /tmp/rc && /tmp/rc/bin/pip install "$(ls dist/ecg_records-*.whl)[viz,engine]"
(cd /tmp && /tmp/rc/bin/python "$OLDPWD/tools/release_smoke.py" --version X.Y.Z --with-viz)
twine upload dist/*.whl dist/*.tar.gz          # the bytes you just tested; never rebuild

Verify that PyPI serves the same hashes as dist/SHA256SUMS (https://pypi.org/pypi/ecg-records/X.Y.Z/json), then tag the release commit:

git tag -a ecg-records-vX.Y.Z <release-commit> -m "ecg-records X.Y.Z"
git push origin ecg-records-vX.Y.Z

5. After the release

  • Add a new ## [Unreleased] section to the changelog.
  • Keep the deprecation schedule: legacy names are removed no earlier than 0.3.0, after a one-release tombstone (Compatibility).
  • Revoke any API token that was scoped to the whole account, and use project-scoped tokens or trusted publishing from then on.

Release history

Version Date Notes
0.1.0 2026-09-24 first release; uploaded with twine after a TestPyPI rehearsal; the uploaded hashes match the artifacts built from commit 1cb2ccc; tag ecg-records-v0.1.0
0.2.0 2026-10-15 schema 1.1.0, file readers, clinical plots, first validated fields; built from the research repository's commit 6826fce (same package source as this repository's tagged commit); uploaded with twine after a TestPyPI rehearsal, together with ecginterpret 0.2.0; PyPI hashes match the local artifacts; tag ecg-records-v0.2.0
0.3.0 2026-10-16 engine split: ecg-records (Apache-2.0, pure Python) + compiled ecg-records-engine (PolyForm Noncommercial or commercial); from commit 45111fa; engine wheels built locally (Linux x86_64 from 45111fa; macOS arm64, Windows AMD64 and Linux aarch64 from 0b3b43b, identical engine source, version metadata set to 0.3.0); macOS x86_64 wheels still to be added; uploaded with twine; PyPI hashes match SHA256SUMS (22/22); tag ecg-records-v0.3.0
0.3.1 2026-10-16 shell-only patch (engine stays 0.3.0): PyPI project links to the docs site and the public repository; documentation version fixes; uploaded with twine; tag ecg-records-v0.3.1
0.3.2 2026-10-19 shell + engine: licence-notice opt-out, summary global caption, readable query results, selector messages, public PWaveConfig, plotting gallery; engine: exact medians (same bytes, ~9% faster), legacy orchestration moved into the engine; validation re-run on the 0.3.2 engine with heart rate and frontal axes promoted to indirectly_validated; per-lead fiducials round ported from the research mainline (LUDB per-lead SD roughly halved for P; per-lead t_offset benchmark validated); staged pipeline moved into the engine, so shell and engine floors rise to 0.3.2 (MINIMUM_ENGINE_VERSION / MINIMUM_LIBRARY_VERSION); Linux x86_64 engine wheels (cp310–cp314, manylinux2014) built locally without Docker (auditwheel repair) from commit 4549d72; uploaded with twine (engine first), PyPI hashes match SHA256SUMS (7/7); clean PyPI installs on 3.10 and 3.13 pass parity and smoke; macOS arm64 and Windows AMD64 engine wheels added 2026-10-20; macOS x86_64 engine wheels (cp310–cp314, macosx_10_15) added 2026-10-22, built on Apple Silicon under Rosetta from fd6ae28 (engine source identical to 4549d72), golden parity byte-identical, PyPI hashes match SHA256SUMS (5/5), clean PyPI installs on 3.10 and 3.14 pass parity and smoke; Linux aarch64 engine wheels pending (that platform resolves to 0.3.1 + 0.3.0); tag ecg-records-v0.3.2