Skip to content

Publishing with a Trusted Publisher

The easy way

Once you have a publisher configured, you can use the PyPA's pypi-publish action to publish your packages.

This looks almost exactly the same as normal, except that you don't need any explicit usernames, passwords, or API tokens: GitHub's OIDC identity provider will take care of everything for you:

jobs:
  pypi-publish:
    name: upload release to PyPI
    runs-on: ubuntu-latest
    # Specifying a GitHub environment is optional, but strongly encouraged
    environment: release
    permissions:
      # IMPORTANT: this permission is mandatory for trusted publishing
      id-token: write
    steps:
      # retrieve your distributions here

      - name: Publish package distributions to PyPI
        uses: pypa/gh-action-pypi-publish@release/v1

If you're moving away from a password or API token-based authentication flow, your diff might look like this:

 jobs:
   pypi-publish:
     name: upload release to PyPI
     runs-on: ubuntu-latest
+    # Specifying a GitHub environment is optional, but strongly encouraged
+    environment: release
+    permissions:
+      # IMPORTANT: this permission is mandatory for trusted publishing
+      id-token: write
     steps:
       # retrieve your distributions here

       - name: Publish package distributions to PyPI
         uses: pypa/gh-action-pypi-publish@release/v1
-        with:
-          username: __token__
-          password: ${{ secrets.PYPI_TOKEN }}

Note the id-token: write permission: you must provide this permission at either the job level (strongly recommended) or workflow level (discouraged). Without it, the publishing action won't have sufficient permissions to identify itself to PyPI.

Note

Using the permission at the job level is strongly encouraged, as it reduces unnecessary credential exposure.

Publishing to indices other than PyPI

The PyPA's pypi-publish action also supports trusted publishing with other (non-PyPI) indices, provided they have trusted publishing enabled (and you've configured your trusted publisher on them). For example, here's how you can use trusted publishing on TestPyPI:

- name: Publish package distributions to TestPyPI
  uses: pypa/gh-action-pypi-publish@release/v1
  with:
    repository-url: https://test.pypi.org/legacy/

The manual way

Warning

STOP! You probably don't need this section; it exists only to provide some internal details about how GitHub Actions and PyPI coordinate using OIDC. If you're an ordinary user, it is strongly recommended that you use the PyPA's pypi-publish action instead.

The process for using an OIDC publisher is:

  1. Retrieve an OIDC token from the OIDC identity provider;
  2. Submit that token to PyPI, which will return a short-lived API key;
  3. Use that API key as you normally would (e.g. with twine)

GitHub is currently the only OIDC identity provider supported, so we'll use it for examples below.

All code below assumes that it's being run in a GitHub Actions workflow runner with id-token: write permissions. That permission is critical; without it, GitHub Actions will refuse to give you an OIDC token.

First, let's grab the OIDC token from GitHub Actions:

resp=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
    "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=pypi")

NOTE: audience=pypi is only correct for PyPI. For TestPyPI, the correct audience is testpypi. More generally, you can access any instance's expected OIDC audience via the {index}/_/oidc/audience endpoint:

$ curl https://pypi.org/_/oidc/audience
{"audience":"pypi"}

The response to this will be a JSON blob, which contains the OIDC token. We can pull it out using jq:

oidc_token=$(jq '.value' <<< "${resp}")

Finally, we can submit that token to PyPI and get a short-lived API token back:

resp=$(curl -X POST https://pypi.org/_/oidc/github/mint-token -d "{\"token\": \"${oidc_token}\"}")
api_token=$(jq '.token' <<< "${resp}")

# tell GitHub Actions to mask the token in any console logs,
# to avoid leaking it
echo "::add-mask::${api_token}"

This API token can be fed into twine or any other uploading client:

TWINE_USERNAME=__token__ TWINE_PASSWORD="${api_token}" twine upload dist/*

This can all be tied together into a single GitHub Actions workflow:

on:
  release:
    types:
      - published

name: release

jobs:
  pypi:
    name: upload release to PyPI
    runs-on: ubuntu-latest
    permissions:
      id-token: write
    steps:
      - uses: actions/checkout@v3

      - uses: actions/setup-python@v4
        with:
          python-version: "3.x"

      - name: deps
        run: python -m pip install -U build

      - name: build
        run: python -m build

      - name: mint API token
        id: mint-token
        run: |
          # retrieve the ambient OIDC token
          resp=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=pypi")
          oidc_token=$(jq '.value' <<< "${resp}")

          # exchange the OIDC token for an API token
          resp=$(curl -X POST https://pypi.org/_/oidc/github/mint-token -d "{\"token\": \"${oidc_token}\"}")
          api_token=$(jq '.token' <<< "${resp}")

          # mask the newly minted API token, so that we don't accidentally leak it
          echo "::add-mask::${api_token}"

          # see the next step in the workflow for an example of using this step output
          echo "api-token=${api_token}" >> "${GITHUB_OUTPUT}"

      - name: publish
        # gh-action-pypi-publish uses TWINE_PASSWORD automatically
        uses: pypa/gh-action-pypi-publish@release/v1
        with:
          password: ${{ steps.mint-token.outputs.api-token }}