diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0a11c1b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,34 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: read + +jobs: + release: + name: Build and create the GitHub release + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Check that the tag matches the package version + run: | + version=$(python -c "import re, pathlib; print(re.search(r'__version__ = \"(.+?)\"', pathlib.Path('pylogkit/_version.py').read_text()).group(1))") + if [ "v$version" != "$GITHUB_REF_NAME" ]; then + echo "Tag $GITHUB_REF_NAME does not match version $version" >&2 + exit 1 + fi + - run: python -m pip install build twine + - run: python -m build + - run: twine check dist/* + - name: Create the GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "$GITHUB_REF_NAME" dist/* --generate-notes --title "$GITHUB_REF_NAME" diff --git a/CHANGELOG.md b/CHANGELOG.md index 0263ca5..c8f7721 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - The package is fully type annotated, ships a PEP 561 `py.typed` marker and is checked with `mypy --strict` in CI. A CI step checks that the wheel contains the marker (#19). +- The version is single-sourced in `pylogkit/_version.py` and exposed as + `pylogkit.__version__`. A release workflow builds and checks the + distribution when a `v*` tag is pushed, verifies that the tag matches the + version, and creates a GitHub release. The process is documented in + `docs/releasing.md`. Publishing to PyPI is deliberately left out until the + distribution name is settled (#20). ### Changed diff --git a/README.md b/README.md index 8b80c22..317943c 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,8 @@ mypy # strict type checking (the package ships py.typed) The same checks run in GitHub Actions on every push and pull request, across Python 3.10 to 3.14, with and without the optional extras, and with python-json-logger 2.x as well as the latest release. -See [CHANGELOG.md](CHANGELOG.md) for what has changed, and the +See [CHANGELOG.md](CHANGELOG.md) for what has changed, [docs/releasing.md](docs/releasing.md) for the +release process, and the [issue tracker](https://github.com/markbac/py-logkit/issues) for planned work. ## Licence diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..3450bf8 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,29 @@ +# Releasing + +Releases follow [Semantic Versioning](https://semver.org/). The version lives in one place, +`pylogkit/_version.py`, and the build reads it from there. + +## Cutting a release + +1. Move the entries under `## Unreleased` in `CHANGELOG.md` into a new `## X.Y.Z - YYYY-MM-DD` + heading, and leave an empty `## Unreleased` section above it. +1. Set `__version__` in `pylogkit/_version.py` to `X.Y.Z`. +1. Open a pull request with those two changes and merge it once CI is green. +1. Tag the merge commit and push the tag: + + ```bash + git tag -a vX.Y.Z -m "Release X.Y.Z" + git push origin vX.Y.Z + ``` + +Pushing a `v*` tag runs `.github/workflows/release.yml`. It checks that the tag matches +`__version__`, builds the sdist and wheel, runs `twine check`, and attaches both to a GitHub release +whose notes are generated from the merged pull requests. + +## Publishing to PyPI + +The workflow does not publish to PyPI yet. The name `pylogkit` is already used there by an +unrelated project (see issue #44), so a distribution name has to be chosen first. Once it is, +add a publish job that uses +[trusted publishing](https://docs.pypi.org/trusted-publishers/) with the `id-token: write` +permission, so that no API token is stored in the repository. diff --git a/pylogkit/__init__.py b/pylogkit/__init__.py index 62700b8..1faac4a 100644 --- a/pylogkit/__init__.py +++ b/pylogkit/__init__.py @@ -9,6 +9,7 @@ logger.info("Hello, logging!") """ +from pylogkit._version import __version__ from pylogkit.log_setup import ( ContextualLoggerAdapter, clear_log_context, @@ -22,6 +23,7 @@ ) __all__ = [ + "__version__", "ContextualLoggerAdapter", "clear_log_context", "get_log_context", diff --git a/pylogkit/_version.py b/pylogkit/_version.py new file mode 100644 index 0000000..8bc1521 --- /dev/null +++ b/pylogkit/_version.py @@ -0,0 +1,7 @@ +"""Single source of truth for the package version. + +The build reads ``__version__`` from this file (see ``pyproject.toml``), so it +can be read without importing the package and its dependencies. +""" + +__version__ = "0.1.0" diff --git a/pyproject.toml b/pyproject.toml index 9096231..744622e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pylogkit" -version = "0.1.0" +dynamic = ["version"] description = "A structured and colourised logging toolkit for Python applications" readme = "README.md" requires-python = ">=3.10" @@ -38,6 +38,9 @@ Homepage = "https://github.com/markbac/py-logkit" Issues = "https://github.com/markbac/py-logkit/issues" Changelog = "https://github.com/markbac/py-logkit/blob/main/CHANGELOG.md" +[tool.setuptools.dynamic] +version = { attr = "pylogkit._version.__version__" } + [tool.setuptools.package-data] pylogkit = ["py.typed"] diff --git a/tests/test_packaging.py b/tests/test_packaging.py index e51f192..440e74f 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -40,6 +40,8 @@ def test_public_api_is_annotated(): missing = [] for name in pylogkit.__all__: obj = getattr(pylogkit, name) + if not callable(obj): + continue members = [obj] if inspect.isclass(obj): members = [ @@ -56,3 +58,27 @@ def test_public_api_is_annotated(): missing.append(f"{name}:{getattr(func, '__qualname__', func)}") assert not missing, missing + + +def test_version_is_single_sourced(): + """``pyproject.toml`` reads the version from ``_version.py`` instead of repeating it.""" + import re + + import pylogkit + from pylogkit import _version + + text = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + + assert 'dynamic = ["version"]' in text + assert "pylogkit._version.__version__" in text + assert not re.search(r'^version = "', text, re.M) + assert pylogkit.__version__ == _version.__version__ + assert re.fullmatch(r"\d+\.\d+\.\d+", _version.__version__) + + +def test_release_workflow_checks_tag_against_version(): + """The release workflow exists and refuses a tag that disagrees with the version.""" + workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + + assert 'tags: ["v*"]' in workflow + assert "_version.py" in workflow