Skip to content

Publishing a Python Package to TestPyPI


Everything you’ve deployed so far in this section has been a web page someone visits in a browser. This chapter is a different kind of “deploy” entirely — the artifact isn’t a page, it’s an installable Python package, and the destination isn’t a server, it’s a package index. The CI mechanics turn out to rhyme with what you already know (build a thing, push it somewhere with a token), but the specifics are genuinely different.

The Package You’re Publishing

A minimal, real installable package — no framework, no dependencies, one function:

devstuffs-hello/
├── pyproject.toml
├── README.md
└── src/
    └── devstuffs_hello/
        └── __init__.py

src/devstuffs_hello/__init__.py:

def hello() -> str:
    return "Hello from devstuffs!"

pyproject.toml:

[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[project]
name = "devstuffs-hello"
version = "0.1.0"
description = "A minimal package for practicing CI/CD publishing."
readme = "README.md"
requires-python = ">=3.9"

README.md:

# devstuffs-hello

A minimal Python package used to practice publishing to TestPyPI from GitHub Actions.

    from devstuffs_hello import hello
    print(hello())

The src/ layout (rather than putting devstuffs_hello/ next to pyproject.toml) is a deliberate, common convention — it prevents Python from accidentally importing your uneditable, uninstalled source directory during testing just because it happens to be on the current path, a subtle bug this layout sidesteps entirely.

A Naming Collision You Need to Avoid

devstuffs-hello is not a name you can actually publish — everyone following this course typing the exact same pyproject.toml would collide on TestPyPI, since package names are unique per index, just like the S3 bucket name in the last chapter. Before you build anything, rename the project in pyproject.toml:

name = "devstuffs-hello-yourname"

Swap yourname for your own username or initials. You don’t need to rename the src/devstuffs_hello/ directory or change the import in your own code — the distribution name (what people pip install) and the import name (what they import in Python) are allowed to differ, and here they deliberately do.

Getting a TestPyPI API Token

TestPyPI is a genuinely separate service from PyPI — separate account, separate login, separate tokens, even though the two sites look nearly identical:

  1. Create an account at https://test.pypi.org if you don’t already have one.
  2. Go to Account settings → API tokens → Add API token.
  3. Scope it to “Entire account” for your first publish of this project (TestPyPI won’t let you scope a token to a project that doesn’t exist yet — you can tighten the scope to just this project after the first successful publish).
  4. Copy the token immediately — it’s shown exactly once.

Add it as a repo secret named TEST_PYPI_API_TOKEN.

The Workflow

Create publish.yml in your Github workflows:

mkdir -p .github/workflows
touch .github/workflows/publish.yml
.github/workflows/publish.yml
name: Publish to TestPyPI

on:
  push:
    tags: ['v*']
  workflow_dispatch:

permissions:
  contents: read

jobs:
  publish:
    runs-on: ubuntu-latest
    environment: testpypi
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Setup Python
        uses: actions/setup-python@v6
        with:
          python-version: '3.12'

      - name: Install build tool
        run: python -m pip install --upgrade build

      - name: Build distribution
        run: python -m build

      - name: Publish to TestPyPI
        uses: pypa/gh-action-pypi-publish@release/v1
        with:
          password: ${{ secrets.TEST_PYPI_API_TOKEN }}
          repository-url: https://test.pypi.org/legacy/

A couple of details worth understanding rather than just copying:

  • python -m build produces both a source distribution (.tar.gz) and a wheel (.whl) in a dist/ folder, reading everything it needs from pyproject.toml. This is the standard, tool-agnostic way to build a Python package — it doesn’t care that you’re using the setuptools backend specifically.
  • pypa/gh-action-pypi-publish picks up whatever’s sitting in dist/ automatically; you don’t tell it which files to upload. It also runs twine check on your distributions before uploading by default, catching malformed metadata before it ever reaches the index.
  • repository-url is what redirects the upload from real PyPI (the action’s default) to TestPyPI instead. Forgetting this line is the single easiest way to accidentally publish a placeholder practice package to the real, permanent PyPI index — real PyPI has no “test” mode to undo it in.
  • This uses a stored API token (password:), not the tokenless “trusted publishing” flow the action also supports — trusted publishing relies on the same OIDC mechanism mentioned as “coming later” in the previous chapter, so this course is deliberately using the token method for now.

Verifying the Publish

Tag a commit and push the tag to trigger the workflow:

git add .
git commit -m 'Initial Commit'
git push -u origin master
git tag v0.1.0
git push origin v0.1.0

Once the workflow succeeds, visit https://test.pypi.org/project/devstuffs-hello-yourname/ — your README should be rendered as the project description, and the version you built should be listed. You can also install it directly from TestPyPI to confirm it actually works:

pip install --index-url https://test.pypi.org/simple/ devstuffs-hello-yourname
python -c "from devstuffs_hello import hello; print(hello())"

Tip

That --index-url points pip entirely at TestPyPI, which doesn’t mirror every package on real PyPI — if your package had real dependencies, this exact install command could fail on missing packages. devstuffs-hello has none, so it isn’t an issue here, but it’s worth knowing why for anything with actual dependencies.

Cleanup

There’s no cost sitting idle here, unlike the AWS resources from the last chapter — but there’s also no real “delete” on a package index once a version is published; the closest thing is yanking it. From the project’s TestPyPI page, under the specific version, you can mark it “yanked,” which hides it from fresh installs (pip install devstuffs-hello-yourname will skip it) while still letting anyone who already pinned that exact version continue to fetch it. That’s the intended safety net for “I published something broken,” not a way to make a practice package disappear entirely.

Important

If you are not planning to further play with TestPyPI or publish more packages, you may want to delete the API token from your account and remove from Github.

Last updated on