1087 words
5 minutes
Publish releases to PyPI from GitHub Actions without a password or token
Anubhav Gain
2024-02-02

Publish releases to PyPI from GitHub Actions without a password or token#

I published a package to PyPI today using their Trusted Publishers mechanism for the first time.

Update 17th January 2024: My python-lib cookiecutter template now implements this pattern, see Publish Python packages to PyPI with a python-lib cookiecutter template and GitHub Actions for details.

Trusted Publishers provides a mechanism for configuring PyPI to allow a specific GitHub Actions workflow to publish releases to PyPI without needing to use a password or token.

It’s based on OpenID Connect, but you don’t need to know the details of that at all.

It took me a few iterations to get it to work, but now that I’ve done it once I plan to use it for all of my PyPI packages going forward.

It only takes three steps:

  1. Tell PyPI which GitHub repository should be allowed to publish a package with a specific name
  2. Configure a GitHub Actions publish workflow to use the pypa/gh-action-pypi-publish@release/v1 action
  3. Publish a release to GitHub that triggers the workflow

In the past I’ve had to create a token on PyPI and paste it into a GitHub Actions secret. That’s no longer necessary with this approach.

Adding a new trusted publisher on PyPI#

This process differs slightly depending on if you are planning on publishing a brand new package or updating an existing one to use Trusted Publishers going forward.

When publishing a brand new package you can instead use a special mechanism called “pending publishers”.

This also lets you reserve a package name before you publish the first version. I like this - in the past I’ve attempted to publish a package only to discover that someone else had already reserved a too-similar name.

You can create a new pending publisher from this page: https://pypi.org/manage/account/publishing/

You need to provide the PyPI project name, the GitHub repository owner and name, the workflow name (the name of a YAML file) and an optional environment name.

Here’s how I filled out that form for my new datasette/datasette-build repository:

GitHub. Read more about GitHub Actions's OpenID Connect support here. PyPI Project Name (required): datasette-build. The project (on PyPI) that will be created when this publisher is used. Owner (required): datasette. The GitHub organization name or GitHub username that owns the repository. Repository name (required): datasette-build. The name of the GitHub repository that contains the publishing workflow. Workflow name (required): publish.ym! The filename of the publishing workflow. This file should exist in the github/workflows/ directory in the repository configured above. Environment name (optional): release. The name of the GitHub Actions environment that the above workflow uses for publishing. This should be configured under the repository's settings. While not required, a dedicated publishing environment is strongly encouraged, especially if your repository has maintainers with commit access who shouldn't have PyPI publishing access.

I used publish.yml as the name of my workflow file.

I also set the environment to release. I don’t fully understand GitHub Actions environments yet, but the PyPI documentation suggested this was a good idea and I think it gives me more flexibility for setting extra permissions in the future.

PyPI says:

While not required, a dedicated publishing environment is strongly encouraged, especially if your repository has maintainers with commit access who shouldn’t have PyPl publishing access.

Creating the GitHub Actions environment#

Since we specified the environment on PyPI we need to create that environment. That can be done in the settings area for the repository - in my case that page was here:

https://github.com/datasette/datasette-build/settings/environments/new

Environments just have a name. I called mine release.

Configuring the workflow#

This took me the most time to figure out. I already have a publish.yml workflow I use for my other projects, which uses twine and a PyPI token to upload packages, after first running the tests.

Here’s the workflow I eventually landed, in .github/workflows/publish.yml:

name: Publish Python Package
on:
release:
types: [created]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: '**/pyproject.toml'
- name: Install dependencies
run: |
pip install -e '.[test]'
- name: Run tests
run: |
pytest
deploy:
runs-on: ubuntu-latest
needs: [test]
environment: release
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: pip
cache-dependency-path: '**/pyproject.toml'
- name: Install dependencies
run: |
pip install setuptools wheel build
- name: Build
run: |
python -m build
- name: Publish
uses: pypa/gh-action-pypi-publish@release/v1

The test job is pretty standard - it sets up a matrix to run the tests against multiple Python versions, then runs pytest.

It’s set to trigger by this block:

on:
release:
types: [created]

This ensures the workflow runs any time a new GitHub release is created for the repository.

Where things get interesting is the deploy job. It runs python -m build to build the .tar.gz and .whl files, then uses the pypa/gh-action-pypi-publish@release/v1 action to publish the package.

Breaking that down a bit:

deploy:
runs-on: ubuntu-latest
needs: [test]
environment: release
permissions:
id-token: write

The environment: release key is needed because we configured an environment in PyPI. I think that can be omitted entirely if the PyPI environment field was left blank.

The permissions block there is essential - it’s required for the OpenID Connect token authentication to work.

needs: [test] means that this job waits for the test job to pass before it runs.

- name: Publish
uses: pypa/gh-action-pypi-publish@release/v1

This is the key line. It uses the pypa/gh-action-pypi-publish action to publish the package.

Note that it doesn’t need any settings - it just works, provided the trusted publisher on PyPI has been configured.

Publishing a release#

With all this in place, all that’s needed to ship a package is to ensure the version is set correctly in the pyproject.toml file (or setup.py file if you’re using that instead), then create a new release on GitHub.

For my repo I create a release using this form: https://github.com/datasette/datasette-build/releases/new

Creating the release triggers the workflow, which runs the tests, builds the package and then publishes it to PyPI.

Here’s the resulting package: pypi.org/project/datasette-build/

Configuring a trusted publisher for an existing package#

Here are the instructions for upgrading an existing package.

I tried this myself for datasette-edit-templates. Here’s the diff. I used this process:

  1. Upgrade the publish.yml deploy step to include environment: release, permissions: id-token: write, a python -m build step and the pypa/gh-action-pypi-publish@release/v1 action.
  2. Navigate to that projkect on PyPI and add a new trusted publisher - for my project that page was https://pypi.org/manage/project/datasette-edit-templates/settings/publishing/
  3. Create the environment called release on the GitHub settings page for that repository.
  4. Update the version number and create a new release.

The PyPI form looked like this:

Add a new publisher form. Owner (required) simonw. Repository name (required) datasette-edit-templates. Workflow name (required) publish.yml Environment name (optional) release.

Publish releases to PyPI from GitHub Actions without a password or token
https://mranv.pages.dev/posts/publish-releases-to-pypi-from-github-actions-without-a-password-or-token/
Author
Anubhav Gain
Published at
2024-02-02
License
CC BY-NC-SA 4.0