From 0313c9d1bfe9fb0739a3c00e80b8dcf66e1ca92c Mon Sep 17 00:00:00 2001 From: Samuel Maurer Date: Wed, 14 Oct 2026 14:35:26 -0700 Subject: [PATCH] ci: publish releases to PyPI with Trusted Publishing Add the Publish workflow used in urbansim and orca. On a published GitHub release it builds the sdist and wheel, runs twine check --strict, installs the wheel and runs the tests against it from outside the checkout (copying the tests and saved Overpass responses into the installed package), checks the version against the tag and the pre-release flag, and uploads through Trusted Publishing after approval in the pypi environment. workflow_dispatch builds and tests without publishing. CONTRIBUTING.md describes the automated release, with the manual upload as the fallback. Version 0.2.dev1. --- .github/workflows/publish.yml | 120 ++++++++++++++++++++++++++++++++++ CHANGELOG.rst | 1 + CONTRIBUTING.md | 12 ++-- osmnet/__init__.py | 2 +- 4 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..6908149 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,120 @@ +name: Publish + +# Builds the distributions for a GitHub release and uploads them to PyPI using +# Trusted Publishing. Merging to main never publishes anything; only publishing a +# release on GitHub (which creates or points at a v* tag) does, and the upload +# step waits for approval in the protected "pypi" environment. See CONTRIBUTING.md. + +on: + release: + types: [published] + workflow_dispatch: # build and verify only, without publishing + +permissions: + contents: read + +# Never run two publishes at once, and never cancel one that is in progress +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + build: + name: Build and verify distributions + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: "3.14" + + - name: Build distributions + run: | + python -m pip install build twine + python -m build + python -m twine check --strict dist/* + ls -l dist + + - name: Install the wheel and run the tests against it + run: | + python -m pip install "$(ls dist/*.whl)[test]" + python -m pip check + # Work from outside the checkout so that "osmnet" resolves to the + # installed wheel rather than the source tree + cd "$RUNNER_TEMP" + python -c "from importlib.metadata import version; print('installed version', version('osmnet'))" + # The tests live inside the package but are left out of the wheel, so + # copy the test folder (with its saved Overpass responses) into the + # installed package and run it from there, exercising the wheel + # rather than the source tree. The live API tests are skipped + installed="$(python -c 'import os, osmnet; print(os.path.dirname(osmnet.__file__))')" + echo "installed package at $installed" + (cd "$GITHUB_WORKSPACE/osmnet" && find . -type d -name tests -print0 | tar -cf - --null -T -) \ + | tar -C "$installed" -xf - + python -m pytest --pyargs osmnet -q -p no:cacheprovider + + - name: Check that the package version matches the release + if: github.event_name == 'release' + env: + PRERELEASE: ${{ github.event.release.prerelease }} + run: | + # Read the version from the installed wheel's metadata, from outside the + # checkout, so the check binds the tag to the artifact being uploaded rather + # than to the source tree (or the egg-info that the build leaves behind) + cd "$RUNNER_TEMP" + expected="${GITHUB_REF_NAME#v}" + actual="$(python -c 'from importlib.metadata import version; print(version("osmnet"))')" + echo "tag $GITHUB_REF_NAME expects version $expected; installed wheel reports $actual" + if [ "$expected" != "$actual" ]; then + echo "::error::the package version does not match the release tag" + exit 1 + fi + if [[ "$actual" =~ (a|b|rc|dev)[0-9]*$ ]]; then + if [ "$PRERELEASE" != "true" ]; then + echo "::error::$actual is a pre-release version, but the GitHub release is not marked as a pre-release" + exit 1 + fi + elif [ "$PRERELEASE" = "true" ]; then + echo "::error::$actual is a final version, but the GitHub release is marked as a pre-release" + exit 1 + fi + + - name: Save distributions + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: dist + path: dist/ + if-no-files-found: error + + publish: + name: Publish to PyPI + needs: build + if: github.event_name == 'release' + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: pypi + url: https://pypi.org/project/osmnet/ + permissions: + id-token: write # for Trusted Publishing; no other access is needed + + steps: + - name: Download distributions + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: dist + path: dist/ + + - name: Upload to PyPI + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + # Files upload one at a time and PyPI refuses re-uploads of a filename, so + # let a re-run after a partial failure finish the release instead of aborting + skip-existing: true diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f65e807..1b34442 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,7 @@ Unreleased * no longer uses GeoPandas' deprecated ``GeoSeries.unary_union`` * the tests replay saved Overpass API responses, so they no longer depend on the live API or on later edits to OpenStreetMap; ``OSMNET_RECORD_OVERPASS=1`` refreshes the saved responses, and the live API tests in ``test_live.py`` run with ``OSMNET_LIVE_TESTS=1`` * CI on GitHub Actions: minimum dependencies on Python 3.10, latest dependencies on Python 3.14 (Linux, macOS, Windows), code style, package build and install, and a strict documentation build; Travis and AppVeyor configurations removed +* releases are published to PyPI by a GitHub Actions workflow using Trusted Publishing, after the distributions are built and the tests pass against the installed wheel; ``CONTRIBUTING.md`` describes the release process v0.1.7 ====== diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 222783f..4d62bf6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,7 +67,7 @@ OSMnet's status is Deprecated / Migration: it supports existing Pandana and Urba - Open a pull request to the main branch, and merge it once the checks pass -- Publish the release on GitHub: create a tag on the merge commit named with a `v` prefix (e.g. `v0.2` for version `0.2`), and use the changelog text as the release notes +- Publish the release on GitHub: create a tag on the merge commit named with a `v` prefix (e.g. `v0.2` for version `0.2`), and use the changelog text as the release notes. Publishing the release starts the `Publish` workflow described below - For anything more than a trivial release, do a dry run first with a release candidate: set the version to e.g. `0.2rc1`, tag it `v0.2rc1`, and mark the GitHub release as a pre-release. Pip ignores pre-releases unless asked for them (`pip install --pre osmnet==0.2rc1`), and the Conda Forge bots ignore them too, so this is a safe way to test the whole process. There's no need to delete the release candidate from PyPI afterward. Then repeat with the final version number @@ -76,14 +76,18 @@ OSMnet's status is Deprecated / Migration: it supports existing Pandana and Urba ## Distributing a release on PyPI (for pip installation): -- Register an account at https://pypi.org with two-factor authentication enabled, ask one of the current maintainers to add you to the project, and create an API token scoped to the OSMnet project +- Publishing is automated by the `Publish` GitHub Actions workflow (`.github/workflows/publish.yml`), which runs when a release is published on GitHub. It builds the source distribution and wheel, checks them with `twine check --strict`, installs the wheel and runs the test suite against it, confirms that the package version matches the release tag (and that pre-release versions go with pre-release GitHub releases), and then uploads the files to PyPI using [Trusted Publishing](https://docs.pypi.org/trusted-publishers/), so no PyPI credentials are stored on GitHub -- Check out the tagged release, `pip install build twine`, delete any old files in `dist/`, and run `python -m build` and `twine check --strict dist/*` +- The upload step runs in the repository's `pypi` deployment environment, which requires approval from a maintainer: once the build job succeeds, the workflow pauses until a reviewer approves the deployment from the workflow run page. Merging to `main` never publishes anything -- Run `twine upload dist/*`, entering `__token__` as the username and the token as the password +- The workflow can also be run by hand from the Actions tab (`workflow_dispatch`) to build and test the distributions without publishing them - Check https://pypi.org/project/osmnet/ for the new version, and try `pip install osmnet` in a fresh environment +- One-time setup, in case it needs to be repeated: a PyPI owner of the project registers the trusted publisher at https://pypi.org/manage/project/osmnet/settings/publishing/ with owner `UDST`, repository `osmnet`, workflow `publish.yml`, and environment `pypi`; and a repository admin creates the `pypi` environment at https://github.com/UDST/osmnet/settings/environments with required reviewers + +- Manual fallback, if the workflow can't be used: register an account at https://pypi.org with two-factor authentication enabled, ask one of the current maintainers to add you to the project, and create an API token scoped to the OSMnet project. Then check out the tagged release, `pip install build twine`, delete any old files in `dist/`, and run `python -m build`, `twine check --strict dist/*`, and `twine upload dist/*`, entering `__token__` as the username and the token as the password + ## Distributing a release on Conda Forge (for conda installation): diff --git a/osmnet/__init__.py b/osmnet/__init__.py index c404377..708c50b 100644 --- a/osmnet/__init__.py +++ b/osmnet/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.2.dev0" +__version__ = "0.2.dev1" version = __version__