# How to Publish Python Packages with Digital Attestations


Digital attestations let PyPI record *who published* each file, not just what the file contains. Each attestation is a Sigstore-signed statement, defined by [PEP 740 (index support for digital attestations)](https://pydevtools.com/handbook/explanation/what-is-pep-740.md), that binds a distribution's hash to the CI workflow that uploaded it.

Attestations require [trusted publishing](https://pydevtools.com/handbook/explanation/why-use-trusted-publishing-for-pypi.md): PyPI only accepts attestations signed by a trusted publisher identity from GitHub Actions, GitLab CI/CD, or Google Cloud. This guide shows the two ways to add them to a GitHub Actions workflow.

## Prerequisites

* A [PyPI](https://pypi.org) account with a [trusted publisher configured](https://pydevtools.com/handbook/how-to/how-to-publish-to-pypi-with-trusted-publishing.md) for your GitHub repository
* A GitHub repository containing a Python package with a [`pyproject.toml`](https://pydevtools.com/handbook/reference/pyproject.toml.md)

## Choose a publishing path

Two publishing steps produce attestations. The [PyPA publish action](https://github.com/pypa/gh-action-pypi-publish) signs and uploads in one step with no configuration. `uv publish` uploads attestations but does not generate them, so it needs [astral-sh/attest-action](https://github.com/astral-sh/attest-action) run first. Pick the PyPA action if you have no other reason to use uv in the publish job; pick uv if you already publish with `uv publish` or want the same tool locally and in CI.

Both workflows split build and publish into separate jobs so that only the publish job holds `id-token: write`, the permission that mints both the upload credential and the signing certificate.

## Publish with the PyPA publish action

Create `.github/workflows/publish.yml`:

```yaml
name: Publish to PyPI

on:
  release:
    types: [published]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7.0.1
      - uses: astral-sh/setup-uv@v10.0.1
      - run: uv build
      - uses: actions/upload-artifact@v7.0.1
        with:
          name: dist
          path: dist/

  publish:
    needs: build
    runs-on: ubuntu-latest
    permissions:
      id-token: write
    environment:
      name: pypi
      url: https://pypi.org/p/<YOUR_PACKAGE_NAME>
    steps:
      - uses: actions/download-artifact@v8.0.1
        with:
          name: dist
          path: dist/
      - uses: pypa/gh-action-pypi-publish@release/v1
```

The action signs each file in `dist/` with the workflow's OIDC identity through Sigstore, then uploads the files and their attestations together. Attestations are on by default; no input is needed.

## Publish with uv publish

Replace the `publish` job's final step with attest-action followed by `uv publish`:

```yaml
  publish:
    needs: build
    runs-on: ubuntu-latest
    permissions:
      id-token: write
    environment:
      name: pypi
      url: https://pypi.org/p/<YOUR_PACKAGE_NAME>
    steps:
      - uses: actions/download-artifact@v8.0.1
        with:
          name: dist
          path: dist/
      - uses: astral-sh/setup-uv@v10.0.1
      - uses: astral-sh/attest-action@v0.0.6
      - run: uv publish --trusted-publishing always
```

`attest-action` signs every file matching `dist/*` (change the `paths` input for other locations) and writes a `<distribution>.publish.attestation` file beside each one. `uv publish` discovers those files and uploads them with their distributions:

```console
$ ls dist/
example-1.0.0-py3-none-any.whl
example-1.0.0-py3-none-any.whl.publish.attestation
example-1.0.0.tar.gz
example-1.0.0.tar.gz.publish.attestation
```

Outside GitHub Actions, generate the same files with the `pypi-attestations` CLI (`uvx pypi-attestations sign dist/*`) from a job that holds a Sigstore-capable OIDC token, such as GitLab CI/CD with `id_tokens`. PyPI's [producing attestations](https://docs.pypi.org/attestations/producing-attestations/) page has the GitLab and Google Cloud recipes.

> [!TIP]
> If `uv publish` targets a private index that rejects attestations, pass `--no-attestations` to skip uploading them.

To rehearse either workflow against a throwaway index first, follow [How to publish to TestPyPI with uv](https://pydevtools.com/handbook/how-to/how-to-publish-to-testpypi-with-uv.md).

## Verify attestations on PyPI

Open your project's PyPI page, select the new release, and check its file list. Each file shows a Provenance entry naming the repository and workflow that published it.

To verify a file cryptographically, run `pypi-attestations` against the published file and your repository. It downloads the file and its provenance through the [PyPI Integrity API](https://docs.pypi.org/api/integrity/), checks that the signing identity matches the repository, and validates the signature:

```console
$ uvx pypi-attestations verify pypi \
    --repository https://github.com/<org>/<repo> \
    pypi:<file>
```

For example, against attrs:

```console
$ uvx pypi-attestations verify pypi \
    --repository https://github.com/python-attrs/attrs \
    pypi:attrs-25.1.0-py3-none-any.whl
OK: attrs-25.1.0-py3-none-any.whl
```

A wrong repository fails with `Verification failed: provenance was signed by repository "python-attrs/attrs", expected "<org>/<repo>"`, and a file uploaded without attestations fails with `Provenance for file "<file>" was not found`. Both exit with status 1.

## Record publisher identities in lockfiles

[pylock.toml](https://pydevtools.com/handbook/explanation/what-is-pep-751.md) defines an `attestation-identities` table for recording each package's publisher, but uv and pip do not write it yet; [Why pylock.toml includes digital attestations](https://pydevtools.com/handbook/explanation/why-pylock-toml-includes-digital-attestations.md) explains what that table will make visible in code review once installers fill it in. An attestation proves who built a file, not what went into it; pair it with [hash verification](https://pydevtools.com/handbook/how-to/how-to-pin-dependencies-with-hashes-in-uv.md) on the consuming side and a [byte-identical build](https://pydevtools.com/handbook/how-to/how-to-build-byte-identical-python-wheels.md) on the publishing side so anyone can rebuild the wheel and compare digests.
