agentleFS
shalombshalomb/agent-skills/skills/python-publish/SKILL.md

python-publish

Skill3 starsChanged 3 months agoMITView on GitHub

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".

  • Installs packages

The file

We split this skill into the rules it gives an agent

6 rules. About 0 are common, 3 are distinctive, and 3 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈3

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 3

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Always validate the token locally before storing it as a secret:

    line 45
  2. Distinctive:

    Use project-scoped tokens in CI — least privilege.

    line 143
  3. Very specific:

    Create pypi (and optionally test-pypi) environments before first publish:

    line 147
  4. Distinctive:

    Add protection rules (required reviewers) for production gating if needed.

    line 156
  5. Very specific:

    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.

    line 160
  6. Very specific:

    If publish.yml doesn't trigger (known GitHub quirk with bot-created releases):

    line 171

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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"          # managed by release-please or manually
requires-python = ">=3.12"
dependencies = [...]

[project.scripts]
mypackage = "mypackage.cli:main"

[project.optional-dependencies]
build = ["twine", "build"]

[tool.hatch.build.targets.wheel]
packages = ["mypackage"]   # only needed if layout is non-standard

No setup.py, no setup.cfg, no MANIFEST.in needed.

Build locally

uv sync --extra build
uv build                    # produces dist/*.whl + dist/*.tar.gz
uv run twine check dist/*   # validate metadata before upload

Validate the PyPI token before CI

Always validate the token locally before storing it as a secret:

TOKEN=$(cat /tmp/pypi.token)

# 1. Build
uv build

# 2. Check distributions
uv run twine check dist/mypackage-*.whl dist/mypackage-*.tar.gz

# 3. Dry-run upload (validates token auth against PyPI)
uv run twine upload \
  --repository-url https://upload.pypi.org/legacy/ \
  --username __token__ \
  --password "$TOKEN" \
  --skip-existing \
  dist/mypackage-*

# If this succeeds, the token is valid and the package is live.
# If 403: token is wrong, expired, or scoped to wrong package.

Store token as GitHub secret

gh secret set PYPI_TOKEN --repo OWNER/REPO < /tmp/pypi.token
gh secret list --repo OWNER/REPO   # verify timestamp updated

publish.yml — GitHub Actions workflow

name: Publish

on:
  release:
    types: [published]     # triggered by release-please creating a GitHub Release

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          # or: uv run pytest

      - run: uv build

      - run: |
          uv sync --extra build
          uv run twine check dist/*

      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/
          if-no-files-found: error

  publish-pypi:
    name: Publish to PyPI
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: pypi
      url: https://pypi.org/p/mypackage
    steps:
      - uses: actions/download-artifact@v8
        with:
          name: dist
          path: dist/

      - uses: pypa/gh-action-pypi-publish@release/v1
        with:
          password: ${{ secrets.PYPI_TOKEN }}
          print-hash: true

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 typeWorks withCreated at
Account-scopedAll packages for the accountpypi.org Account Settings
Project-scopedOne specific package onlypypi.org package management
Test PyPI tokentest.pypi.org onlytest.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

# PyPI index has a ~30s propagation delay
sleep 30
curl -s https://pypi.org/pypi/mypackage/json | \
  python3 -c "import json,sys; d=json.load(sys.stdin); print(d['info']['version'])"

# Or install and check
uvx mypackage --version
pip install mypackage==1.2.3 --dry-run

Common errors

ErrorCauseFix
403 Invalid or non-existent authentication informationWrong token or token scoped to different packageValidate token with twine locally first
400 File already existsVersion already on PyPI (immutable)Bump version — PyPI doesn't allow re-upload
startup_failure in workflowActions permissions policy blocking pypa/*See github-actions-permissions skill
Workflow didn't triggerrelease: published event didn't fireToggle draft on the release to re-fire
Wrong publish.yml ranWorkflow runs from tag ref, not HEADFix is in next release

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.