Python package build and release
This guide covers building edox-ops distributions and publishing them to:
| Registry | Purpose |
|---|---|
| GitLab PyPI | Project package registry (first-class internal mirror) |
| TestPyPI | Pre-release validation before public PyPI |
| PyPI | Public Python Package Index |
Publishing uses standard python -m build and twine. Credentials are never
committed; use environment variables or GitLab CI/CD variables.
Installing from PyPI (end users)
Operator install steps (venv, version pins, host notes) are in the published Installation guide. Quick reference after a release is on the public index:
pip install edox-ops
edox-ops --version
Package page: pypi.org/project/edox-ops.
Versioning
- Source of truth:
src/edox_ops/__version__(also exposed asedox_ops.__version__). pyproject.tomlreads the version dynamically from that attribute.- Git tags: use
v<version>, e.g.v0.1.0for package version0.1.0. - CI on tags checks that the tag matches the built package version.
Bump __version__ in a commit before tagging:
git tag -a v0.1.0 -m "Release 0.1.0"
git push gitlab v0.1.0
Local setup
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev,release]"
The release extra installs build and twine.
Build and validate
make dist-clean # optional: remove dist/, build/, egg-info
make build # python -m build → dist/*.tar.gz, dist/*.whl
make dist-check # twine check dist/*
Or without make:
python -m build
python -m twine check dist/*
Artifacts land in dist/ (gitignored).
Credentials (local)
Copy .pypirc.example to ~/.pypirc and fill tokens locally,
or export variables (preferred for CI parity):
| Variable | Used for |
|---|---|
TWINE_USERNAME | Usually __token__ for PyPI and TestPyPI |
TWINE_PASSWORD | PyPI API token (upload scope) |
TWINE_PASSWORD_TESTPYPI | TestPyPI API token (if different from PyPI) |
GITLAB_PYPI_PROJECT_ID | Numeric GitLab project ID (local GitLab uploads) |
GITLAB_TOKEN | GitLab PAT with write_package_registry (local GitLab uploads) |
PyPI and TestPyPI tokens: create at pypi.org and test.pypi.org.
Never commit .pypirc with real passwords (the repo ignores .pypirc).
TestPyPI
export TWINE_USERNAME=__token__
export TWINE_PASSWORD="$TWINE_PASSWORD_TESTPYPI" # or TWINE_PASSWORD
make publish-testpypi
Test installation (TestPyPI does not mirror all dependencies):
pip install --index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ \
edox-ops==0.1.0
edox-ops --help
Public PyPI
export TWINE_USERNAME=__token__
export TWINE_PASSWORD="$TWINE_PASSWORD_PYPI"
make publish-pypi
GitLab PyPI registry
CI (recommended): push a version tag; job publish:gitlab uses CI_JOB_TOKEN.
Local:
export GITLAB_PYPI_PROJECT_ID=12345678 # Project → Settings → General
export GITLAB_TOKEN=glpat-... # scope: write_package_registry
make publish-gitlab
Registry URL pattern:
https://gitlab.com/api/v4/projects/<project_id>/packages/pypi
On self-managed GitLab, replace the host with your instance.
GitLab CI release pipeline
On git tags (v*), after lint, test, and integration:
| Job | Stage | Behavior |
|---|---|---|
package:build | build | python -m build, twine check, artifact dist/ |
publish:gitlab | publish | Upload to GitLab PyPI (automatic on tag) |
package:smoke | release | Install dist/*.whl in a fresh venv; CLI smoke |
publish:testpypi | publish | Upload to TestPyPI (automatic after package:smoke) |
package:smoke-testpypi | publish | pip install from TestPyPI |
publish:pypi | publish | Upload to PyPI (manual) |
package:smoke-pypi | publish | pip install from PyPI |
Required CI/CD variables
Configure in Settings → CI/CD → Variables (masked, protected):
| Variable | Job | Notes |
|---|---|---|
TWINE_PASSWORD_TESTPYPI | publish:testpypi | TestPyPI API token |
TWINE_PASSWORD_PYPI | publish:pypi | PyPI API token |
publish:gitlab uses built-in CI_JOB_TOKEN (no extra secret).
Optional: set variables protected so only protected tags/branches can use them.
Recommended release flow
Follow the Automated release pipeline — the canonical plan for all maintainers. Summary:
- Bump
__version__and merge tomaster. - Tag
vX.Y.Zand push the tag. - CI runs
package:build→package:smoke→publish:gitlab+publish:testpypi→package:smoke-testpypi. - Click
publish:pypiwhen TestPyPI smoke is green (only manual step). - CI runs
package:smoke-pypiafter public upload.
Local dry run before tagging: make dist-smoke.
Repo hygiene
Automated tag pipeline (canonical maintainer plan): release-pipeline.md.
Branch protection, Dependabot, and first-tag setup: release-hygiene.md.
Package history: CHANGELOG.md.
Troubleshooting
- Tag / version mismatch: CI fails in
package:buildifv1.2.3≠__version__. - Duplicate upload: PyPI and TestPyPI reject re-uploading the same version; bump version or yank on the index.
- TestPyPI install fails on dependencies: use
--extra-index-url https://pypi.org/simple/as above.