Automating PyPI releases: a CI/CD workflow for Python SDKs (September 2026)

13 min read

Most Python SDK releases still end the same way: an engineer on a laptop runs python -m build, then twine upload, holding a PyPI API token that has permanent write access to a package other companies install in production. The failure modes are all downstream of that arrangement. A typed version number drifts from the API contract, a stale working tree gets packaged and uploaded, a token leaks into a log, and PyPI accepts every one of those without complaint because uploads are irreversible. The thesis of this playbook is narrow: a PyPI release for a generated Python SDK should be a function of the API definition and the CI run that produced it, which means the version is computed rather than typed and the upload is authenticated by a short-lived OIDC identity rather than a stored credential.

TLDR:

  • PyPI uploads are permanent. A filename can never be reused, even after the release is deleted, so a bad publish can only be superseded and yanked.
  • Trusted publishing exchanges a GitHub Actions OIDC token for a short-lived upload credential, which removes the long-lived API token from the pipeline entirely and turns on PEP 740 provenance attestations by default.
  • The version number belongs to the API definition, not to the release engineer. Compute it from a contract diff and pass it into the build.
  • Test the built wheel, not the source tree, across the full requires-python range before the upload step runs. A generated SDK that imports cleanly on 3.13 can fail on the floor version.
  • Gate the publish job behind a GitHub Actions environment so majors and first releases require an approval, and keep the build and publish jobs separate.
  • Fern generates Python SDKs from an API definition and publishes them to PyPI through GitHub Actions with token: OIDC, with generated unit and mock-server tests running as the gate.

What a Python SDK release actually ships

A PyPI release is two artifacts and a block of metadata, and each has a specification behind it that the pipeline has to respect.

  • A wheel and a source distribution. Generated API clients are pure Python, so the wheel is a single py3-none-any build with no compiled extensions and no cibuildwheel matrix. The sdist still matters: it is what downstream packagers, distro maintainers, and reproducible-build tooling consume, and PEP 625 requires newly uploaded sdist filenames to use the normalized project name, so build tooling that predates it produces files PyPI rejects.
  • Metadata in pyproject.toml. PEP 621 standardized the [project] table, and PEP 517 standardized the build-backend interface, which is why the backend choice between hatchling, setuptools, and Poetry is no longer load-bearing for consumers. For a generated SDK it is not even a team decision: the generator emits the manifest, and relitigating it per release is how hand-patched divergence starts.
  • A version string that resolvers act on. PEP 440 defines the format, and pip's compatible-release operator (~=) reads it as a compatibility assertion. A requires-python floor is part of the same promise: raising it is a breaking change for consumers even when no endpoint moved.

The build step itself is commodity. python -m build with twine upload is the long-standing path; uv build and uv publish do the same work in one toolchain and handle OIDC natively, and uv publish invalidates the short-lived credential after the upload attempt. Either is fine. The interesting decisions are on both sides of it.

Authenticating to PyPI without a long-lived token

PyPI has required two-factor authentication for all accounts since January 1, 2024, which hardened the human login path and left automation on API tokens: long-lived bearer credentials sitting in CI secrets, rotated rarely, scoped loosely, and readable by anyone who can add a workflow file to the repository.

Trusted publishing replaces them. The publish job requests an OIDC token from GitHub, pypa/gh-action-pypi-publish exchanges it with PyPI, and PyPI validates the claim against a publisher it was configured to trust before minting a short-lived upload credential. The trust record is a tuple: repository owner, repository name, workflow filename, and optionally a GitHub Actions environment name. Nothing is stored on the CI side except the id-token: write permission.

name: publish
on:
  release:
    types: [published]
 
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.9", "3.13"]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
      - run: pip install ".[dev]" && pytest
 
  publish:
    needs: test
    runs-on: ubuntu-latest
    environment: pypi
    permissions:
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - run: pipx run build
      - uses: pypa/gh-action-pypi-publish@release/v1

Three details decide whether this holds up in practice:

  • The environment name is part of the trust boundary. Naming an environment in the publisher configuration means a workflow that omits it cannot publish, even from the same repository. Attaching required reviewers to that environment converts the publish step into an approval gate without any bespoke tooling.
  • Attestations are on by default. Since PyPI shipped PEP 740 support, the PyPA action generates Sigstore-signed attestations for every file when the upload runs through trusted publishing, binding the artifact to the workflow that built it. Consumers can verify that an SDK on PyPI came from the repository it claims to.
  • Trusted publishing currently does not work inside a reusable workflow. This is the constraint that breaks platform teams who centralize CI. The supported pattern is a non-reusable caller workflow that invokes the reusable workflow for build and test, then performs the publish in its own job.

Token-based auth stays available for repositories that cannot restructure, and it is worth keeping the tokens project-scoped rather than account-scoped. It is a fallback, not a destination.

Deciding the version without a human typing it

The version number is the part of a release most teams keep manual, and it is the part a generated SDK can compute. The public surface of a generated client is a function of two versioned inputs: the API definition and the generator configuration. Anything a consumer can import is in scope, which includes method names, model class names, enum members, and the exception hierarchy.

That makes the bump derivable from a diff rather than declared in a commit message. Conventional Commits and tools like python-semantic-release infer intent from human-authored messages, which works well for hand-written libraries and poorly for a repository whose commits are bot-generated replacements of a directory tree. The workable inversion: compute the bump from the contract diff, then emit the changelog and the tag as outputs of that classification.

Two practical rules follow. First, ship 1.0.0 rather than lingering on 0.x, because a 0.x version makes every range operator useless: ~=0.4.1 resolves only inside the 0.4 series under PEP 440, and Poetry's ^0.4.1 caps at <0.5.0, so each minor release becomes a manual bump in every consumer's manifest. Second, pass the computed version into the build explicitly instead of deriving it from whatever tag happens to be reachable. Fern's Python generator takes it as a flag:

fern generate --group python-sdk --version 2.4.0

Keeping the number on the command line means the release is reproducible from the definition plus a version, which is what makes evergreen SDKs practical to maintain across many releases.

A GitHub Actions pipeline for PyPI releases

The ordering below is the design. Nothing builds before the definition validates, and nothing uploads before the artifact that will be uploaded has been tested.

1. Validate the API definition

Lint the spec before anything reads it: Spectral rulesets for OpenAPI, or fern check for the API definition and generator configuration. Some spec problems are specifically Python problems. Fern's no-conflicting-parameter-names rule catches the case where a header parameter and a query parameter normalize to the same identifier in generated code, which would otherwise ship as a SyntaxError in the published package.

2. Generate and build in one job

Regenerate the client at the computed version, then build the wheel and sdist from that tree. Upload them as job artifacts. Every later step consumes those files rather than rebuilding, so the thing tested is byte-identical to the thing published.

3. Test the artifact, not the repository

Install the built wheel into a clean virtualenv on each supported interpreter and run the suite against it. This is the step that catches packaging bugs rather than code bugs: a module missing from the wheel, a py.typed marker that never made it into the package data, a subpackage excluded by a bad glob. twine check dist/* validates that the long description renders on PyPI, which is a cosmetic failure that is nonetheless permanent.

4. Gate the release

A GitHub Actions environment with required reviewers is the standard mechanism, and it should trigger on the cases that deserve a human: the first publish of a package name, a major bump, and a change to the publisher configuration itself. Routine patch and minor releases can run unattended. This is the line between automated versioning and automatic versioning.

5. Publish, then verify from the index

Upload with trusted publishing, then install the package from PyPI in a fresh job and import it. PyPI's CDN is eventually consistent, so a short retry loop belongs here. Publishing to TestPyPI first is useful for validating the workflow itself, though it is a separate namespace with its own trusted-publisher configuration, and dependency resolution against it is unreliable because most dependencies are not mirrored there.

6. Record what shipped

Tag the commit and record the version each language published. That record is the baseline the next run diffs against, which is what keeps the pipeline from re-deriving the bump against the wrong starting point.

What to test before an SDK reaches PyPI

A generated client's test suite is a compatibility test, not a functional one. The API behind it is tested elsewhere; what needs verifying here is that the package a developer installs behaves like the contract it was generated from.

  • Both ends of the requires-python range. A matrix over the floor version and the newest supported interpreter catches the majority of real breakage, since the middle versions rarely differ in ways that affect a pure-Python client. Pydantic model construction and typing constructs are the usual failure points.
  • Mock-server tests over unit tests. Asserting that a method serializes the right request and deserializes the right response requires a server. Fern generates both unit tests and mock-server wire tests from the same API definition and runs them in CI on every pull request and release, which is the cheapest available check that the generated client round-trips correctly. Broader API testing practice applies, with the caveat that the target under test is the package rather than the service.
  • Static type checking as a consumer would see it. Run mypy or pyright against a small script that imports the installed package and exercises a few calls. This catches type annotations that are internally consistent but unusable from outside the package.
  • An import smoke test with no dev dependencies installed. The most common broken release is a package that imports a test-only or lint-only dependency at module scope.

Failure modes that only appear after the upload

PyPI is append-only in the ways that matter, and the pipeline has to be designed around that rather than assuming a rollback exists.

  • A filename can never be reused. PyPI rejects an upload whose filename has ever existed for the project, and deleting the release does not free it. There is no re-cutting 2.4.0 after a bad build; the only path forward is 2.4.1.
  • Deleting is worse than yanking. Deleting a release breaks every lockfile pinned to it. PEP 592 yanking is the correct tool: a yanked file is ignored during resolution unless a specifier pins to it exactly with == or ===, so existing locked builds keep working while new resolutions move on.
  • Partial publishes in multi-language programs. When one definition feeds nine registries, npm can succeed while PyPI fails on a name collision or a metadata rejection. The pipeline needs to treat per-registry publishing as independently retryable rather than rolling the whole release back, because the successful uploads are already permanent. This is a large part of why automated package publishing is harder to hand-roll than it first looks.
  • The package name is a land grab. Reserve the PyPI name before the first real release, even if the initial upload is a placeholder, and configure the trusted publisher on it immediately. A squatted name on a public SDK is a supply-chain problem, not a branding one.

Publishing to a private index alongside PyPI

Enterprise API programs frequently publish the same client twice: to PyPI for public consumers and to an internal index for partner or internal-only variants. Artifactory, AWS CodeArtifact, and Azure Artifacts all speak the PyPI upload API, so the publish step differs only in the index URL and the credential, which means a second job rather than a second pipeline.

The consumption side is where this goes wrong. Adding an internal index via --extra-index-url makes pip search both indexes and install whichever it resolves to, which is the dependency-confusion attack in one flag. Use --index-url to point at a single index that proxies PyPI, and enforce it in the lockfile rather than in developer documentation. Fern publishes generated SDKs to private registries including Artifactory alongside the public registries, running after the wire tests validate the client against the spec.

How Fern automates PyPI publishing for Python SDKs

Fern generates idiomatic, type-safe Python SDKs from an API definition using Pydantic models, and treats the PyPI release as an output of that definition rather than a separate manual step. The publishing target is configured in generators.yml with location: pypi, a package-name, and token: OIDC for trusted publishing, which requires the Python generator at 4.38.1 or later; fern generate --group python-sdk writes the GitHub Actions workflow with the OIDC permissions and the pypi environment already set. Release behavior is selected with mode: release, mode: pull-request, or mode: push, so the same configuration supports unattended publishing or a reviewed pull request per release, with the generated unit and mock-server tests as the gate. The full setup is documented in Fern's Python publishing guide, and because one definition drives the other eight languages, the Python pipeline is one group in a multi-language SDK generation configuration rather than a standalone project.

Final thoughts on automating PyPI releases

The release pipeline works when nothing in it is typed by hand. The version comes from a diff of the API definition, the artifact comes from the job that tested it, and the upload credential comes from an OIDC exchange that expires minutes later. What remains manual is the one decision that should be: whether a breaking change ships this week. Everything else in a Python SDK release is mechanical, and PyPI's permanence is the reason to mechanize it rather than an argument for keeping a careful human in the loop.

Teams comparing options across the category will find that release automation is where SDK generation tools differ most, since generating plausible Python is considerably easier than publishing it safely nine times a month. To see automated generation, versioning, and PyPI publishing run against a real API definition, book a demo.

FAQ

How do you automate Python SDK publishing with GitHub Actions?

Trigger a workflow on a published GitHub release, build the wheel and sdist in one job, test the built artifact on each supported interpreter, then publish from a separate job that has id-token: write permission and targets an environment registered as a trusted publisher on PyPI. The pypa/gh-action-pypi-publish action handles the OIDC exchange, so no API token is stored in the repository. Fern generates this workflow directly from generators.yml when the output location is pypi.

What is PyPI trusted publishing and is it better than an API token?

Trusted publishing lets PyPI mint a short-lived upload credential in exchange for a GitHub Actions OIDC token, validated against a repository, workflow, and environment that the project owner registered in advance. It is better than an API token on three counts: nothing long-lived exists to leak, the trust is scoped to a specific workflow rather than a whole account or project, and PEP 740 provenance attestations are generated by default so consumers can verify which workflow built a given file.

Should a Python SDK version be computed or set manually?

Computed, for generated clients. The public surface of a generated SDK is determined by the API definition and the generator configuration, both of which are in version control, so the correct bump is derivable from a diff rather than from a commit message. Manual version selection is where contract changes and version numbers drift apart, and pip acts on the number without asking.

Can the same pipeline publish to a private registry instead of PyPI?

Yes. Artifactory, AWS CodeArtifact, and Azure Artifacts implement the PyPI upload API, so the publish job changes only the index URL and the credential. Fern supports automated publishing to private registries including Artifactory alongside public ones, which matters for partner-only or internal SDK variants. On the install side, point pip at a single index that proxies PyPI rather than adding an extra index URL, which avoids the dependency-confusion failure mode.

What happens if a broken version is published to PyPI?

It cannot be replaced. PyPI refuses any upload whose filename has previously existed for that project, and deleting the release does not make the version available again. Yank the bad version under PEP 592 so pip stops resolving to it while exact pins keep working, then publish a patch. Designing the pipeline so the artifact is tested before the upload step runs is considerably cheaper than managing this after the fact.