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__.pysrc/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:
- Create an account at
https://test.pypi.orgif you don’t already have one. - Go to Account settings → API tokens → Add API token.
- 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).
- 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.ymlname: 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 buildproduces both a source distribution (.tar.gz) and a wheel (.whl) in adist/folder, reading everything it needs frompyproject.toml. This is the standard, tool-agnostic way to build a Python package — it doesn’t care that you’re using thesetuptoolsbackend specifically.pypa/gh-action-pypi-publishpicks up whatever’s sitting indist/automatically; you don’t tell it which files to upload. It also runstwine checkon your distributions before uploading by default, catching malformed metadata before it ever reaches the index.repository-urlis 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.0Once 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.