From 878acbc2e7ef329699381e1e18ff58df25359606 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt <2812720+tleonhardt@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:52:36 -0500 Subject: [PATCH] Publish releases from GitHub Actions with PyPI provenance PyPI only accepts PEP 740 attestations on files uploaded by a Trusted Publisher, so a local upload with an API token can't carry provenance. Add a publish.yml workflow that builds the tagged release and uploads it to PyPI or TestPyPI with pypa/gh-action-pypi-publish, which signs and uploads attestations. The build runs in a separate job with read-only permissions, and only the upload job can request an OIDC token. make publish and make publish-test now start that workflow on the tag at HEAD with the GitHub CLI and wait for the run, after checking that the tag on origin matches the local one. uv-publish is no longer used, so drop it from the dev dependency group. Document the release flow and the one-time setup: the Trusted Publishers, the pypi/testpypi environments (tag-only, cmd2-admins approval, no admin bypass), and the ruleset limiting release tags to cmd2-admins. --- .github/CONTRIBUTING.md | 35 +++++++++++++-- .github/workflows/publish.yml | 84 +++++++++++++++++++++++++++++++++++ Makefile | 31 ++++++++++--- pyproject.toml | 1 - 4 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/publish.yml diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index a3224f0f4..18a231e21 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -80,7 +80,7 @@ for a list of dependencies needed for building `cmd2`. | [setuptools](https://pypi.org/project/setuptools/) | `72.1.0` | Python package management | | [setuptools-scm](https://github.com/pypa/setuptools-scm) | `8.0.4` | Manage your versions by scm tags | -> [uv-publish](https://pypi.org/project/uv-publish/) is also needed for publishing releases to PyPI, +> The [GitHub CLI](https://cli.github.com/) (`gh`) is also needed for publishing releases to PyPI, > but that is something only core maintainers need to worry about. #### Additional prerequisites for developing cmd2 @@ -766,7 +766,12 @@ other branches can be deleted. All releases are tagged so that they can be repro ## Publishing a new release Since 0.9.2, the process of publishing a new release of `cmd2` to [PyPi](https://pypi.org/) has been -mostly automated. The manual steps are all git operations. Here's the checklist: +mostly automated. Releases are built and uploaded by the +[Publish](https://github.com/python-cmd2/cmd2/actions/workflows/publish.yml) GitHub Actions workflow +using [Trusted Publishing](https://docs.pypi.org/trusted-publishers/), which also attaches +[provenance attestations](https://docs.pypi.org/attestations/) to every file. The `make publish` +targets start that workflow with the [GitHub CLI](https://cli.github.com/) (`gh`), so you need `gh` +installed and authenticated. Here's the checklist: 1. Make sure you're on the proper branch (almost always **main**) 1. Make sure all the unit tests pass with `make test` @@ -774,9 +779,31 @@ mostly automated. The manual steps are all git operations. Here's the checklist: 1. Make sure `CHANGELOG.md` describes the version and has the correct release date 1. Add a git tag representing the version number using `make tag TAG=x.y.z` - Where x, y, and z are all small non-negative integers -1. (Optional) Run `make publish-test` to clean, build, and upload a new release to +1. (Optional) Run `make publish-test` to build and upload the tagged release to [Test PyPi](https://test.pypi.org) -1. Run `make publish` to clean, build, and upload a new release to [PyPi](https://pypi.org/) +1. Run `make publish` to build and upload the tagged release to [PyPi](https://pypi.org/) + +Both targets wait for the workflow run to finish and fail if it fails. Only members of the +`cmd2-admins` team can publish: the upload job waits until one of them approves it, either from the +workflow run page that `make publish` prints or from the repository's Actions tab. + +### One-time publishing setup + +Trusted Publishing needs this configuration, which only a project owner can change: + +- On [PyPI](https://pypi.org/manage/project/cmd2/settings/publishing/), add a GitHub Trusted + Publisher with owner `python-cmd2`, repository `cmd2`, workflow `publish.yml`, and environment + `pypi` +- On [Test PyPI](https://test.pypi.org/manage/project/cmd2/settings/publishing/), add the same + Trusted Publisher, but with environment `testpypi` +- In the GitHub repository settings, create the `pypi` and `testpypi` environments. For each one: + - Limit deployments to tags matching `*.*.*`, so the workflow can only publish a tagged release + - Add the `cmd2-admins` team as required reviewers and turn off administrator bypass, so only + members of that team can approve an upload +- In the GitHub repository rulesets, add an active tag ruleset named "Release tags" that targets + `refs/tags/*.*.*`, restricts creations, updates, and deletions, and lets only the `cmd2-admins` + team bypass it. This stops anyone else from creating or moving the release tags the workflow + publishes from ## Acknowledgement diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 000000000..c0a175af4 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,84 @@ +# Builds cmd2 from a release tag and publishes it to PyPI or TestPyPI using Trusted Publishing. +# pypa/gh-action-pypi-publish generates and uploads PEP 740 attestations (provenance) by default. +# +# Started by `make publish` / `make publish-test`, which run: +# gh workflow run publish.yml --ref -f target= +# +# One-time setup is described in the "Publishing a new release" section of .github/CONTRIBUTING.md. +# +# For documentation on GitHub Actions Workflows, see: +# https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions +name: Publish + +on: + workflow_dispatch: + inputs: + target: + description: Package index to publish to + type: choice + options: + - testpypi + - pypi + default: testpypi + +permissions: {} + +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build distributions + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Require a tag + if: github.ref_type != 'tag' + run: | + echo "::error::Publish must run on a release tag, not a branch (got ${GITHUB_REF})" + exit 1 + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # Needed for setuptools_scm to work correctly + persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + with: + enable-cache: false # Never restore a cache into a release build + - name: Validate tag + run: uv run --no-project scripts/validate_tag.py + - name: Build + run: uv build + - name: Upload distributions + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: dist + path: dist/ + if-no-files-found: error + + publish: + name: Publish to ${{ inputs.target }} + needs: build + runs-on: ubuntu-latest + environment: + name: ${{ inputs.target }} + url: https://${{ inputs.target == 'testpypi' && 'test.' || '' }}pypi.org/p/cmd2 + permissions: + id-token: write # Required for Trusted Publishing and for signing attestations + steps: + - name: Download distributions + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: dist + path: dist/ + - name: Publish to TestPyPI + if: inputs.target == 'testpypi' + uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 + with: + repository-url: https://test.pypi.org/legacy/ + - name: Publish to PyPI + if: inputs.target == 'pypi' + uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 diff --git a/Makefile b/Makefile index 2cd99f33f..b5dcb655e 100644 --- a/Makefile +++ b/Makefile @@ -74,15 +74,34 @@ validate-tag: ## Check to make sure that a tag exists for the current HEAD and i @echo "🚀 Validating version tag" @uv run scripts/validate_tag.py +# Start the GitHub Actions publish workflow (.github/workflows/publish.yml) on the tag at HEAD and wait for it to finish. +# The workflow builds the release and uploads it with Trusted Publishing, which attaches PEP 740 provenance. +# Argument: the package index to publish to (pypi or testpypi). Requires the GitHub CLI (gh). +define publish-via-workflow +set -e; \ +tag=$$(git describe --exact-match --tags); \ +if [ "$$(git ls-remote --tags origin "refs/tags/$$tag" | cut -f1)" != "$$(git rev-parse "$$tag")" ]; then \ + echo "Tag '$$tag' is missing from origin or differs from it. Push it with: git push origin $$tag"; \ + exit 1; \ +fi; \ +url=$$(gh workflow run publish.yml --ref "$$tag" -f target=$(1) | grep -o 'https://[^ ]*/actions/runs/[0-9]*') || { \ + echo "Could not start the publish workflow or find its run URL"; \ + exit 1; \ +}; \ +echo "🚀 Workflow run: $$url"; \ +echo "🚀 A member of the cmd2-admins team must approve the deployment on that page before the upload starts"; \ +gh run watch "$${url##*/}" --exit-status +endef + .PHONY: publish-test -publish-test: validate-tag build ## Test publishing a release to PyPI, uses token from ~/.pypirc file. - @echo "🚀 Publishing: Dry run." - @uv run uv-publish --repository testpypi +publish-test: validate-tag ## Publish the tag at HEAD to TestPyPI via GitHub Actions + @echo "🚀 Publishing to TestPyPI via GitHub Actions" + @$(call publish-via-workflow,testpypi) .PHONY: publish -publish: validate-tag build ## Publish a release to PyPI, uses token from ~/.pypirc file. - @echo "🚀 Publishing." - @uv run uv-publish +publish: validate-tag ## Publish the tag at HEAD to PyPI, with provenance, via GitHub Actions + @echo "🚀 Publishing to PyPI via GitHub Actions" + @$(call publish-via-workflow,pypi) # Define variables for files/directories to clean BUILD_DIRS = build dist *.egg-info diff --git a/pyproject.toml b/pyproject.toml index 5b500b2a8..1eda7c141 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,6 @@ dev = [ "ruff>=0.16.0", "textual-tty>=0.4.0 ; sys_platform != 'win32'", "ty>=0.0.75", - "uv-publish>=1.3", "zensical>=0.0.57", ] docs = [