diff --git a/.github/workflows/builder_releaser.yml b/.github/workflows/builder_releaser.yml index 4e44e72c..3548c3ac 100644 --- a/.github/workflows/builder_releaser.yml +++ b/.github/workflows/builder_releaser.yml @@ -9,6 +9,8 @@ on: - ".github/workflows/builder_releaser.yml" - "builder/**/*" - "dicogis/**/*.py" + - "MANIFEST.in" + - "pyproject.toml" tags: - "*" @@ -18,6 +20,8 @@ on: paths: - ".github/workflows/builder_releaser.yml" - "builder/" + - "MANIFEST.in" + - "pyproject.toml" # Globals env: @@ -29,6 +33,66 @@ env: # Jobs definition jobs: + build-python-package: + name: "🐍 Python package" + runs-on: ubuntu-24.04 + + permissions: + contents: read + + steps: + - name: Get source code + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install build tools + run: python -m pip install -U build twine + + - name: Build source and wheel distributions + run: python -m build + + - name: Check distributions metadata + run: python -m twine check --strict dist/* + + # PyPI never accepts the same version twice: refuse to publish a tag + # which doesn't match the version declared in dicogis/__about__.py + - name: Check that the git tag matches the package version + if: startsWith(github.ref, 'refs/tags/') + env: + TAG: ${{ github.ref_name }} + run: | + python - <<'EOF' + import os + import sys + from pathlib import Path + + from packaging.utils import parse_wheel_filename + from packaging.version import Version + + tag_version = Version(os.environ["TAG"]) + (wheel,) = Path("dist").glob("*.whl") + built_version = parse_wheel_filename(wheel.name)[1] + if tag_version != built_version: + sys.exit( + f"Tag {tag_version} does not match the built version " + f"{built_version}: fix dicogis/__about__.py or the tag." + ) + print(f"Tag and built version match: {built_version}") + EOF + + - name: Save distributions as artifact + uses: actions/upload-artifact@v7 + with: + name: python_package + path: dist/* + if-no-files-found: error + build-ubuntu: name: "🐧 Ubuntu LTS" runs-on: ubuntu-24.04 @@ -214,6 +278,7 @@ jobs: name: "πŸš€ GitHub Release" runs-on: ubuntu-latest needs: + - build-python-package - build-ubuntu - build-windows permissions: @@ -223,6 +288,12 @@ jobs: if: startsWith(github.ref, 'refs/tags/') steps: + - name: Download built Python package (sdist and wheel) + uses: actions/download-artifact@v8 + with: + name: python_package + path: builds/python/ + - name: Download built package for Linux (Ubuntu) - GUI uses: actions/download-artifact@v8 with: @@ -253,8 +324,35 @@ jobs: discussion_category_name: announcements fail_on_unmatched_files: true files: | + builds/python/* builds/ubuntu/cli/*.bin builds/ubuntu/gui/*.bin builds/windows/cli/*.exe builds/windows/gui/*.exe generate_release_notes: true + + release-pypi: + name: "🐍 Release on PyPI" + runs-on: ubuntu-latest + needs: + - build-python-package + + if: startsWith(github.ref, 'refs/tags/') + + environment: + name: pypi + url: https://pypi.org/p/dicogis + + permissions: + # PyPI Trusted Publishing (OIDC): no API token stored in the repository + id-token: write + + steps: + - name: Download built Python package (sdist and wheel) + uses: actions/download-artifact@v8 + with: + name: python_package + path: dist/ + + - name: Publish package to PyPI + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 diff --git a/CLAUDE.md b/CLAUDE.md index 24f112d7..932b6c60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -283,6 +283,19 @@ by `builder/win_version_info.py`. CI's single-sourced from `dicogis/__about__.py::__version__` (`pyproject.toml` reads it via `[tool.setuptools.dynamic]`). +The same workflow publishes the package to PyPI: its `build-python-package` job +builds the sdist and the wheel, runs `twine check --strict` and, on a tag, +refuses a tag that doesn't match `__version__`; its `release-pypi` job (Trusted +Publishing, `pypi` GitHub environment, no API token) then uploads them, and the +`release` job attaches them to the GitHub release. `tests/test_packaging.py` +fails when a `.ui` or `.qm` file isn't covered by +`[tool.setuptools.package-data]`: add the pattern there when introducing a new +UI subfolder or kind of data file. Don't add +`setuptools-scm` to `build-system.requires`: it is not configured here, but as +a build-time file finder it would make the sdist ship every git-tracked file +(docs, images…) and the wheel the `.ts` translation sources. The release +procedure is in `docs/development/releasing.md`. + A root-level `Dockerfile` (multi-stage, based on the official `ghcr.io/osgeo/gdal:ubuntu-small-*` image so GDAL's Python bindings are already present and version-matched β€” no `gdal` extra to compile) packages diff --git a/docs/development/releasing.md b/docs/development/releasing.md index 2df9b269..fb5ec272 100644 --- a/docs/development/releasing.md +++ b/docs/development/releasing.md @@ -5,6 +5,25 @@ 1. Apply a git tag with the relevant version: `git tag -a 0.3.0 {git commit hash} -m "New awesome feature"` 1. Push commit and tag to main branch: `git push --tags` +Pushing the tag triggers the `builder_releaser.yml` workflow. It builds the executables and the Python package (source and wheel distributions), creates the GitHub release with all of them attached, then publishes the Python package to [PyPI](https://pypi.org/project/dicogis/). + +Before building, the workflow runs `twine check --strict` on the distributions and checks that the tag matches the version declared in `__about__.py` (compared as [PEP 440](https://peps.python.org/pep-0440/) versions: `4.0.0-beta9` and `4.0.0b9` are the same version). + +On a pull request touching `pyproject.toml`, `MANIFEST.in` or the workflow itself, the Python package is built and checked without publishing anything. + +## PyPI publication + +The publication relies on [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OpenID Connect): no API token is stored in the repository. + +One-time setup, before the first release: + +1. On PyPI, in the account's [publishing settings](https://pypi.org/manage/account/publishing/), add a _pending publisher_ with the project name `dicogis`, the owner `Guts`, the repository `DicoGIS`, the workflow `builder_releaser.yml` and the environment `pypi` +1. On GitHub, in the repository settings, create an environment named `pypi` (optionally with required reviewers, to confirm each publication) + +The first successful publication turns the pending publisher into a regular one. + +> A version can be published only once on PyPI: even deleted, a file can't be uploaded again under the same name, so moving the tag and re-running the workflow doesn't fix a faulty release. [Yank it](https://pypi.org/help/#yanked) and publish a new version. + ## Unpublish a tag ```sh @@ -22,20 +41,20 @@ If the CI/CD fails for any reason, here comes the manual procedure: 1. Install required packages: ```sh - python -m pip install -U build twine wheel + python -m pip install -U build twine ``` -1. Install package in editable mode: +1. Clean previous builds and build package artifacts (source and wheel distributions, in `dist/`): ```sh - python -m pip install -e . + rm -rf dist/ + python -m build ``` -1. Clean previous builds and build package artifacts: +1. Check built artifacts: ```sh - rm -rf dist/ - python -m build --no-isolation --sdist --wheel --outdir dist/ . + twine check --strict dist/* ``` 1. Upload built artifacts to PyPi: diff --git a/pyproject.toml b/pyproject.toml index 64124533..d42c161e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,6 @@ [build-system] -requires = ["setuptools>=64", "setuptools-scm>8"] +# setuptools>=77 is needed for the SPDX `license` expression (PEP 639) +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 00000000..b4ab2924 --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,100 @@ +#! python3 # noqa E265 + +""" +Usage from the repo root folder: + +.. code-block:: bash + # for whole tests + python -m unittest tests.test_packaging + # for specific test + python -m unittest tests.test_packaging.TestPackaging.test_ui_and_translation_files_are_packaged +""" + +# standard library +import tomllib +import unittest +from pathlib import Path + +# 3rd party +from packaging.requirements import Requirement + + +# ############################################################################ +# ########## Globals ############# +# ################################ + +PROJECT_ROOT = Path(__file__).parent.parent +PACKAGE_DIR = PROJECT_ROOT / "dicogis" + +# Qt files loaded at runtime: if one is missing from the wheel, the GUI breaks +RUNTIME_DATA_SUFFIXES = {".ui", ".qm"} + + +# ############################################################################ +# ########## Classes ############# +# ################################ + + +class TestPackaging(unittest.TestCase): + """Test the packaging configuration declared in pyproject.toml.""" + + @classmethod + def setUpClass(cls) -> None: + """Read pyproject.toml once.""" + with (PROJECT_ROOT / "pyproject.toml").open("rb") as pyproject_file: + cls.pyproject = tomllib.load(pyproject_file) + + cls.package_data_patterns: list[str] = cls.pyproject["tool"]["setuptools"][ + "package-data" + ]["dicogis"] + + def test_package_data_patterns_match_files(self) -> None: + """A pattern matching nothing is a typo or a moved folder: files would be + silently left out of the wheel. + """ + for pattern in self.package_data_patterns: + with self.subTest(pattern=pattern): + self.assertTrue(any(PACKAGE_DIR.glob(pattern))) + + def test_ui_and_translation_files_are_packaged(self) -> None: + """Every .ui and .qm file must be covered by a package-data pattern, e.g. a + new dialog in a new subfolder. + """ + packaged = { + path + for pattern in self.package_data_patterns + for path in PACKAGE_DIR.glob(pattern) + } + runtime_data = { + path + for path in PACKAGE_DIR.rglob("*") + if path.suffix in RUNTIME_DATA_SUFFIXES + } + + missing = sorted( + str(path.relative_to(PROJECT_ROOT)) for path in runtime_data - packaged + ) + self.assertEqual(missing, [], "Not declared in [tool.setuptools.package-data]") + + def test_build_requires_setuptools_supports_spdx_license(self) -> None: + """An SPDX `license` expression (PEP 639) is only understood by setuptools + >= 77: with a lower floor, builds without isolation fail. + """ + if not isinstance(self.pyproject["project"]["license"], str): + self.skipTest("The license is not declared as an SPDX expression.") + + requirements = [ + Requirement(requirement) + for requirement in self.pyproject["build-system"]["requires"] + ] + setuptools = next(req for req in requirements if req.name == "setuptools") + + self.assertNotIn("76.1.0", setuptools.specifier) + self.assertIn("77.0.0", setuptools.specifier) + + +# ############################################################################ +# ####### Stand-alone run ######## +# ################################ +if __name__ == "__main__": + unittest.main()