Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions .github/workflows/builder_releaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
- ".github/workflows/builder_releaser.yml"
- "builder/**/*"
- "dicogis/**/*.py"
- "MANIFEST.in"
- "pyproject.toml"
tags:
- "*"

Expand All @@ -18,6 +20,8 @@
paths:
- ".github/workflows/builder_releaser.yml"
- "builder/"
- "MANIFEST.in"
- "pyproject.toml"

# Globals
env:
Expand All @@ -29,6 +33,66 @@

# 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

Check warning on line 55 in .github/workflows/builder_releaser.yml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Omitting "--only-binary :all:" can lead to the execution of setup scripts. Make sure it is safe here.

See more on https://sonarcloud.io/project/issues?id=Guts_DicoGIS&issues=AaFULXR6_5QdOfVWX8f0&open=AaFULXR6_5QdOfVWX8f0&pullRequest=521

Check warning on line 55 in .github/workflows/builder_releaser.yml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=Guts_DicoGIS&issues=AaFULXR6_5QdOfVWX8f1&open=AaFULXR6_5QdOfVWX8f1&pullRequest=521
Comment thread
Guts marked this conversation as resolved.
Dismissed
Comment thread
Guts marked this conversation as resolved.
Dismissed

- 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
Expand Down Expand Up @@ -214,6 +278,7 @@
name: "🚀 GitHub Release"
runs-on: ubuntu-latest
needs:
- build-python-package
- build-ubuntu
- build-windows
permissions:
Expand All @@ -223,6 +288,12 @@
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:
Expand Down Expand Up @@ -253,8 +324,35 @@
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
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 25 additions & 6 deletions docs/development/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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]
Expand Down
100 changes: 100 additions & 0 deletions tests/test_packaging.py
Original file line number Diff line number Diff line change
@@ -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()
Loading