Releasing to PyPI
PyPI is the index pip and uv install from. radiowriter has been there since 0.1.0 (3 September 2026), which is what makes uv tool install radiowriter work. This page is how the next one gets there.
What has to exist first
- A PyPI account — pypi.org/account/register, with two-factor authentication, which is now mandatory for publishing.
- The name reserved.
radiowriterwas free at the time of writing; the first upload claims it. Nobody else can take it afterwards. - A tagged version.
versioninpyproject.tomland the git tag should agree, or the release page will disagree with whatpipinstalled.
The safe way: Trusted Publishing
You can upload with an API token, but then the token has to live somewhere — your laptop, or a GitHub secret — and a token that can publish is a token that can publish something that is not yours.
Trusted Publishing removes the token. PyPI is told "the workflow release.yml in gmadevs/Radiowriter may publish radiowriter", and GitHub signs each run so PyPI can check it. Nothing is stored anywhere.
Set it up once, before the first release. Twice, in fact — PyPI and TestPyPI are separate sites with separate accounts, and the only difference in the form is the environment name:
| PyPI | TestPyPI | |
|---|---|---|
| Where | pypi.org | test.pypi.org |
| Project name | radiowriter | radiowriter |
| Owner | gmadevs | gmadevs |
| Repository | Radiowriter | Radiowriter |
| Workflow | release.yml | release.yml |
| Environment | pypi | testpypi |
On GitHub both environments have to exist too: Settings → Environments → New environment, named pypi and testpypi.
"Pending publisher" is the form to use before the project exists — the first successful run creates it.
When it says invalid-publisher
The workflow fails with "valid token, but no corresponding publisher" and then prints the claims it sent — repository, workflow_ref, environment. Those are what the form has to match. Most of the time the mismatch is the environment, or the publisher having been added on the other one of the two sites.
Trying it on TestPyPI first
test.pypi.org is a full copy of PyPI that nobody installs from by accident. It is a separate site with a separate account: registering on PyPI does not register you there, and the pending publisher has to be added again, with testpypi as the environment.
Where a tag goes is decided by its shape, so trying something out is not a separate procedure to remember:
| Tag | Goes to |
|---|---|
v0.1.0rc1, v0.2.0a3, v1.0.0b2 | TestPyPI |
v0.1.0, v1.2.3 | PyPI |
The version in pyproject.toml has to say the same thing, and the workflow stops if it does not — a release page that contradicts what pip installed is a thing nobody notices until it matters. The comparison goes through packaging rather than string equality, because PEP 440 normalises: 0.1.0-rc1 and 0.1.0rc1 are the same version written two ways.
Push a release candidate, then check the result is installable:
uv tool install --index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ radiowriterThe two index URLs are needed because TestPyPI does not carry Streamlit or pandas: the package comes from the test index, its dependencies from the real one.
Making a release
# 1. the version, in one place
$EDITOR pyproject.toml # version = "0.1.1"
# 2. the tests, all of them
python3 check_rules.py && python3 check_structure.py && \
python3 check_search.py && python3 check_journals.py && python3 check_app.py
# 3. tag it and push
git commit -am "Version 0.1.1"
git tag v0.1.1
git push && git push --tagsThe tag triggers release.yml, which builds the wheel and the sdist, uploads them to PyPI and opens a GitHub release.
A few minutes later:
uv tool install radiowriter # or: uv tool upgrade radiowriterChecking a build before it goes out
pip install build twine
python -m build # writes dist/
twine check dist/* # README renders on the project page?
pip install dist/radiowriter-*.whl # into a throwaway venv
radiowriter --versiontwine check catches the commonest embarrassment: a README that PyPI refuses to render, leaving the project page blank.
A version cannot be replaced
Once 0.1.0 is uploaded it is that file forever. A version can be yanked — hidden from new installs while staying available to anything that pinned it — but never overwritten. Which is why TestPyPI exists, and why release candidates are worth the extra minute.
Installing from git instead
Still perfectly good, and it needs no index at all:
uv tool install git+https://github.com/gmadevs/RadiowriterThe difference is what you get: the index gives you the last released version, git gives you whatever is on main at that moment — which may be ahead of any release, and may be mid-change.