| name | python-publish |
| description | Set up and validate a modern Python publish pipeline — hatchling build backend, uv, twine token validation, PyPI/TestPyPI publishing via GitHub Actions. Use when packaging a Python project for PyPI, wiring a CI publish workflow, or diagnosing publish failures. Triggers on "publish to PyPI", "python package release", "twine upload", "pypi token", "gh-action-pypi-publish". |
Python Publish
Modern Python publish pipeline: hatchling + uv + twine + PYPI_TOKEN.
pyproject.toml setup
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "mypackage"
version = "1.2.3"
requires-python = ">=3.12"
dependencies = [...]
[project.scripts]
mypackage = "mypackage.cli:main"
[project.optional-dependencies]
build = ["twine", "build"]
[tool.hatch.build.targets.wheel]
packages = ["mypackage"]
No setup.py, no setup.cfg, no MANIFEST.in needed.
Build locally
uv sync --extra build
uv build
uv run twine check dist/*
Validate the PyPI token before CI
Always validate the token locally before storing it as a secret:
TOKEN=$(cat /tmp/pypi.token)
uv build
uv run twine check dist/mypackage-*.whl dist/mypackage-*.tar.gz
uv run twine upload \
--repository-url https://upload.pypi.org/legacy/ \
--username __token__ \
--password "$TOKEN" \
--skip-existing \
dist/mypackage-*
Store token as GitHub secret
gh secret set PYPI_TOKEN --repo OWNER/REPO < /tmp/pypi.token
gh secret list --repo OWNER/REPO
publish.yml — GitHub Actions workflow
name: Publish
on:
release:
types: [published]
jobs:
build:
name: Build distribution
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v7
with:
version: "latest"
- run: uv python install 3.12
- run: uv sync --extra test
- run: make test
- run: uv build
- run: |
uv sync --extra build
uv run twine check dist/*
- uses: actions/upload-artifact@v4
with:
name: dist
path:
Do NOT add a Test PyPI stage unless you have a separate TEST_PYPI_TOKEN.
The production PYPI_TOKEN is rejected by test.pypi.org with 403.
PyPI token scopes
| Token type | Works with | Created at |
|---|
| Account-scoped | All packages for the account | pypi.org Account Settings |
| Project-scoped | One specific package only | pypi.org package management |
| Test PyPI token | test.pypi.org only | test.pypi.org (separate account) |
Use project-scoped tokens in CI — least privilege.
GitHub environments
Create pypi (and optionally test-pypi) environments before first publish:
gh api repos/OWNER/REPO/environments/pypi --method PUT --input - <<'EOF'
{}
EOF
Without the environment, the job will fail or skip silently.
Add protection rules (required reviewers) for production gating if needed.
Workflow runs from the tag ref, not HEAD
When a GitHub Release is created from tag v1.2.3, the publish workflow
runs the publish.yml at that tag, not the current main.
Consequence: if you fix publish.yml after tagging, the fix doesn't apply
until the next release tag. The workflow file at the tag is always used.
Triggering publish after release-please
release-please creates the GitHub Release automatically after the Release PR
merges. The release: published event fires and triggers publish.yml.
If publish.yml doesn't trigger (known GitHub quirk with bot-created releases):
RELEASE_ID=$(gh api repos/OWNER/REPO/releases/latest --jq '.id')
gh api repos/OWNER/REPO/releases/$RELEASE_ID --method PATCH --field draft=true --jq '.draft'
gh api repos/OWNER/REPO/releases/$RELEASE_ID --method PATCH --field draft=false --jq '{tag_name,published_at}'
This re-publishes the release and re-fires the event.
Verify publish succeeded
sleep 30
curl -s https://pypi.org/pypi/mypackage/json | \
python3 -c "import json,sys; d=json.load(sys.stdin); print(d['info']['version'])"
uvx mypackage --version
pip install mypackage==1.2.3 --dry-run
Common errors
| Error | Cause | Fix |
|---|
403 Invalid or non-existent authentication information | Wrong token or token scoped to different package | Validate token with twine locally first |
400 File already exists | Version already on PyPI (immutable) | Bump version — PyPI doesn't allow re-upload |
startup_failure in workflow | Actions permissions policy blocking pypa/* | See github-actions-permissions skill |
| Workflow didn't trigger | release: published event didn't fire | Toggle draft on the release to re-fire |
Wrong publish.yml ran | Workflow runs from tag ref, not HEAD | Fix is in next release |