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 = [