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
35 changes: 31 additions & 4 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -766,17 +766,44 @@ 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`
1. Make sure latest year in `LICENSE` matches current year
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

Expand Down
84 changes: 84 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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 <tag> -f target=<pypi|testpypi>
#
# 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
31 changes: 25 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
Loading