Skip to content
Back to skills

Python Package Publishing

ASecurity

Builds, verifies, and publishes Python packages with hatchling, hatch-vcs

  • 4 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added June 12, 2026
developmentpythonrustgoc++bashdockergitapibackendci/cd

Works with

  • cli
  • api

Security analysis

A92/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned June 12, 2026

npx -y skills add paulpas/agent-skill-router --skill python-package-publishing --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Python Package Publishing?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Python Package Publishing
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/paulpas-python-package-publishing/badge)](https://www.skillsdirectory.com/skills/paulpas-python-package-publishing)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---




name: python-package-publishing
description: Builds, verifies, and publishes Python packages with hatchling, hatch-vcs
  dynamic versioning, twine checks, test pypi, and GitHub Actions release CI/CD.
license: MIT
compatibility: opencode
metadata:
  version: "1.0.0"
  domain: coding
  triggers: python package, publish to pypi, how do i publish a python package, build
    wheel sdist, pyproject.toml hatchling, twine check, test pypi, github actions
    release
  archetypes:
  - tactical
  - generation
  anti_triggers:
  - brainstorming
  - vague ideation
  - code golf
  - over-engineering
  response_profile:
    verbosity: low
    directive_strength: high
    abstraction_level: operational
  role: implementation
  scope: implementation
  output-format: code
  content-types:
  - code
  - guidance
  - config
  - examples
  - do-dont
  related-skills: coding/python-module-structure, modern-python-development, coding/software-delivery-pipelines




---




# Python Package Publishing Engine

Builds, verifies, and publishes production-ready Python packages using hatchling as the build backend, hatch-vcs for git-tag-driven dynamic versioning, twine for pre-upload verification, and GitHub Actions for automated release CI/CD. Handles wheel and sdist distribution creation, CLI entry points, optional dependency groups, and native extension builds.

## TL;DR Checklist

- [ ] Set `[build-system]` to `hatchling` with `requires-python >= 3.10`
- [ ] Configure `hatch-vcs` for git-tag-driven dynamic versioning — never hardcode versions
- [ ] Run `python -m build` to produce both `.whl` and `.tar.gz` artifacts
- [ ] Verify every artifact with `twine check dist/*` before uploading
- [ ] Publish to TestPyPI first with `twine upload --repository testpypi dist/*`, then PyPI
- [ ] Define CLI entry points under `[project.scripts]` for console commands
- [ ] Group optional dependencies under `[project.optional-dependencies]`
- [ ] Configure native extension build in `hatch_build.py` when C/C++/Rust extensions are needed

---

## When to Use

- Packaging a Python library or application for distribution on PyPI
- Setting up a CI/CD release pipeline that builds and publishes on git tags
- Adding CLI entry points so users can run your package as a command-line tool
- Organizing optional dependencies (e.g., `pip install mypkg[dev,ml,gpu]`)
- Building Python packages that include native extensions (C/C++/Rust)
- Migrating from `setup.py` / `setuptools` to modern `pyproject.toml` + hatchling

---

## When NOT to Use

- Internal-only tools not meant for public or private PyPI distribution — use direct `pip install -e .` in development
- Simple one-off scripts that don't need versioning, entry points, or dependency management
- Projects already using Poetry, PDM, or uv as their primary toolchain — those have their own packaging flows. This skill is specifically for hatchling-based workflows.

---

## Core Workflow

1. **Define pyproject.toml Metadata** — Configure project name, description, authors, license, Python version constraint, dependencies, and optional dependency groups under `[project]`. Declare the build system using `[build-system]` with `hatchling`.
   **Checkpoint:** Every required field (`name`, `version` or `dynamic = ["version"]`, `description`, `readme`, `requires-python`, `dependencies`) is present. No `setup.py` file exists.

2. **Configure Dynamic Versioning with hatch-vcs** — If using git-tag-driven versions, set `dynamic = ["version"]` under `[project]` and configure `[tool.hatch.version.source = "vcs"]`. Tag releases with `v1.2.3` format; the build system derives the version from the nearest annotated tag.
   **Checkpoint:** The nearest ancestor tag follows PEP 440-compliant version format (e.g., `v1.2.3`). No hardcoded version string exists in source files.

3. **Set Up CLI Entry Points** — Define console scripts under `[project.scripts]`. Each entry maps a command name to a module path: function pair. For GUI or package-level execution, also add `[project.scripts]` and `src/package/__main__.py`.
   **Checkpoint:** Every command listed under `[project.scripts]` resolves to an existing callable at runtime. Test with `pip install -e .` and run the command.

4. **Add Optional Dependency Groups** — Declare feature-specific dependencies under `[project.optional-dependencies]`. Each group key becomes a pip extras specifier: `pip install mypackage[dev,test]`. Name groups by their use case (`dev`, `test`, `docs`, `ml`, `gpu`).
   **Checkpoint:** No dependency appears in both `dependencies` and an optional group — that creates installation ambiguity.

5. **Configure Native Extension Builds** — If your package includes C/C++/Rust extensions, create a `hatch_build.py` file in the project root. Hatchling calls functions in this file at build time to compile extensions. Use `Extension` classes and `get_extensions()` or `get_config()` hooks.
   **Checkpoint:** Extensions compile against the correct Python include paths and ABI. The built extension is present in the wheel under the expected package path.

6. **Build Distributions** — Run `python -m build` from the project root. This produces a `.whl` (wheel) for the current platform and a `.tar.gz` (source distribution) compatible with all platforms. Verify both artifacts exist in `dist/`.
   **Checkpoint:** Both `dist/*.whl` and `dist/*.tar.gz` are present. Check wheel metadata with `python -m zipfile -l dist/*.whl | head -20`.

7. **Verify with Twine** — Run `twine check dist/*` to validate long description rendering on PyPI, check for missing metadata, and ensure no insecure package contents. Fix any warnings or errors before uploading.
   **Checkpoint:** `twine check` reports zero errors and zero warnings. Review the rendered README preview at https://pypi.org/help/#rendered-readme.

8. **Publish to TestPyPI First** — Upload to TestPyPI using an API token (not a password) via `twine upload --repository testpypi dist/*`. Install from TestPyPI in a clean environment to verify: `pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ mypackage`.
   **Checkpoint:** The package installs cleanly from TestPyPI and all entry points, imports, and optional features work.

9. **Publish to Production PyPI** — After confirming the TestPyPI install works, publish to production: `twine upload dist/*` (or use API tokens with `--repository pypi`). Never skip the TestPyPI step for new packages or major version bumps.
   **Checkpoint:** The package appears on https://pypi.org/project/<your-package>/. Verify downloads and metadata are correct.

10. **Automate Release CI/CD** — Configure GitHub Actions to build, verify, and publish on git tags matching `v*`. Use the `pypa/gh-action-pypi-publish` action for secure uploads with PyPI API token stored in secrets.
    **Checkpoint:** Pushing a tag `v1.2.3` triggers the workflow, which builds artifacts, runs tests, publishes to TestPyPI, and then PyPI on merge to main.

---

## Implementation Patterns

### Pattern 1: Complete pyproject.toml with hatch-vcs Dynamic Versioning

This is the canonical `pyproject.toml` for a publishable Python package using hatchling as the build backend with git-tag-driven versioning. Replace placeholder values with your project metadata.

```toml
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"

[project]
name = "my-package"
dynamic = ["version"]
description = "A production-ready Python package with CLI entry points and optional features"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [
    { name = "Jane Developer", email = "jane@example.com" },
]
keywords = ["python", "package", "cli", "tool"]
classifiers = [
    "Development Status :: 4 - Beta",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: MIT License",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
    "Programming Language :: Python :: 3.13",
    "Operating System :: OS Independent",
]
dependencies = [
    "click>=8.1",
    "pydantic>=2.0,<3",
    "httpx>=0.27",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.0",
    "pytest-cov>=4.1",
    "ruff>=0.4",
    "mypy>=1.10",
    "types-click>=7.1",
]
test = [
    "httpx>=0.27",
    "pytest-asyncio>=0.23",
]
docs = [
    "sphinx>=7.0",
    "sphinx-rtd-theme>=2.0",
    "myst-parser>=2.0",
]
ml = [
    "numpy>=1.26",
    "pandas>=2.2",
    "scikit-learn>=1.4",
]

[project.scripts]
mycli = "my_package.cli:main"
mytool = "my_package.commands.run:execute"

[project.gui-scripts]
mygui = "my_package.gui:main"

[tool.hatch.version]
source = "vcs"

[tool.hatch.version.raw-options]
version_scheme = "post-release"
local_scheme = "no-local-version"

[tool.hatch.build.targets.wheel]
packages = ["src/my_package"]

[tool.hatch.build.targets.sdist]
# Include test data and docs in source distribution
include = [
    "/src",
    "/tests",
    "/README.md",
    "/LICENSE",
]

[tool.hatch.metadata]
allow-direct-references = true
```

### Pattern 2: Native Extension Build with hatch_build.py

When your package includes compiled extensions (C, C++, Rust), hatchling uses a build hook file to invoke the compiler. This pattern shows a C extension building against the Python C API with proper include paths and ABI flags.

```python
# hatch_build.py — Called by hatchling during `python -m build`

import os
import sys
from pathlib import Path

from hatchling.build import ConfigurablePlugin
from hatchling.plugin import hookimpl


class ExtensionBuilder(ConfigurablePlugin):
    """Builds native C extensions for my_package."""

    def get_sources(self, target_type, target_id, config):
        """Provide source files for the wheel build."""
        if target_type != "wheel":
            return []

        ext_dir = Path("src/my_package/_native")
        if not ext_dir.exists():
            return []

        return list(ext_dir.glob("*.c")) + [Path("src/my_package/_native/CMakeLists.txt")]

    @hookimpl
    def build_wheel(self, wheel_directory, config_settings=None, data=None):
        """Build C extensions into the wheel directory before packaging."""
        from setuptools import Extension, setup
        from setuptools.command.build_ext import build_ext

        ext = Extension(
            "my_package._native.core",
            sources=["src/my_package/_native/core.c"],
            include_dirs=[
                sys.base_prefix + "/include/python" + str(sys.version_info.major) + "." + str(sys.version_info.minor),
                "src/my_package/_native/include",
            ],
            define_macros=[("Py_LIMITED_API", "0x030A0000")],  # ABI-stable for Python 3.10+
        )

        # Compile extension in-place so it can be copied into the wheel
        setup(
            ext_modules=[ext],
            script_args=["build_ext", "--inplace"],
        )

        return super().build_wheel(wheel_directory, config_settings, data)


def build(hook_config, build_context):
    """Entry point called by hatchling at build time."""
    builder = ExtensionBuilder(hook_config or {})
    return builder.build_wheel(
        wheel_directory=build_context.context_dir / "dist",
        config_settings=None,
        data=None,
    )
```

### Pattern 3: CLI Entry Point Implementation (BAD vs. GOOD)

Proper CLI setup requires an entry point module that handles argument parsing, error handling, and a clean return code. Avoid putting all logic at the top level of `__init__.py`.

```python
# ❌ BAD — monolithic cli.py with no error handling, no exit codes
import click

@click.command()
def mycli():
    from my_package.core import run
    result = run()
    print(result)

if __name__ == "__main__":
    mycli()
```

```python
# ✅ GOOD — structured CLI with error handling, exit codes, and graceful degradation
"""Command-line interface for my-package.

Entry point registered via [project.scripts] in pyproject.toml:
    mycli = "my_package.cli:main"
"""

import sys
import logging
from typing import NoReturn

import click

logger = logging.getLogger("my_package.cli")


def _die(message: str, code: int = 1) -> NoReturn:
    """Print an error message to stderr and exit with the given code."""
    click.echo(f"Error: {message}", err=True)
    sys.exit(code)


@click.group()
@click.version_option(package_name="my-package")
@click.option("--verbose", "-v", is_flag=True, help="Enable debug logging.")
@click.option("--quiet", "-q", is_flag=True, help="Suppress all non-error output.")
@click.pass_context
def main(ctx: click.Context, verbose: bool, quiet: bool) -> None:
    """my-package — production CLI tool."""
    ctx.ensure_object(dict)

    if verbose and quiet:
        _die("Cannot use both --verbose and --quiet simultaneously.", code=2)

    level = logging.DEBUG if verbose else (logging.WARNING if quiet else logging.INFO)
    logging.basicConfig(
        level=level,
        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
    )


@main.command()
@click.argument("input_path", type=click.Path(exists=True, dir_okay=False))
@click.option("--output", "-o", type=click.Path(writable=True), default=None)
@click.option("--format", "fmt", type=click.Choice(["json", "csv", "yaml"]), default="json")
@click.pass_context
def process(ctx: click.Context, input_path: str, output: str | None, fmt: str) -> int:
    """Process an input file and write results."""
    from my_package.core import Engine

    try:
        engine = Engine()
        result = engine.process(input_path, format_type=fmt)
    except FileNotFoundError as exc:
        _die(f"Input file not found: {input_path}")
    except ValueError as exc:
        _die(f"Invalid input data: {exc}")

    if output:
        result.to_file(output)
        click.echo(f"Wrote results to {output}", err=bool(ctx.obj.get("verbose")))
    else:
        click.echo(result.serialize(fmt))

    return 0


if __name__ == "__main__":
    sys.exit(main())
```

### Pattern 4: GitHub Actions Release CI/CD Pipeline

This workflow builds and publishes the package on every git tag that matches `v*`. It uses a two-stage publish: first to TestPyPI for validation, then to production PyPI after manual approval.

```yaml
# .github/workflows/release.yml
name: Release

on:
  push:
    tags:
      - "v*"

permissions:
  contents: read

jobs:
  build-and-test:
    name: Build and test on Python ${{ matrix.python }}
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python: ["3.10", "3.11", "3.12", "3.13"]
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Required for hatch-vcs to find tags

      - name: Set up Python ${{ matrix.python }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python }}

      - name: Install build dependencies
        run: |
          python -m pip install --upgrade pip
          pip install build twine hatch-vcs

      - name: Build distributions
        run: python -m build

      - name: Verify with twine
        run: twine check dist/*

      - name: Run tests
        run: |
          pip install -e ".[dev,test]"
          pytest tests/ --cov=my_package --cov-report=xml -q

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          file: ./coverage.xml

  publish-testpypi:
    needs: build-and-test
    name: Publish to TestPyPI
    runs-on: ubuntu-latest
    permissions:
      id-token: write  # Required for trusted publishing
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install build tools
        run: pip install build twine

      - name: Build
        run: python -m build

      - name: Upload to TestPyPI
        uses: pypa/gh-action-pypi-publish@release/v1
        with:
          repository-url: https://test.pypi.org/legacy/
          verbose: true
          print-hash: true

  publish-pypi:
    needs: publish-testpypi
    name: Publish to Production PyPI
    runs-on: ubuntu-latest
    if: startsWith(github.ref, 'refs/tags/v')
    permissions:
      id-token: write  # Trusted publishing via PyPI trusted publishers
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install build tools
        run: pip install build twine

      - name: Build and publish to PyPI
        uses: pypa/gh-action-pypi-publish@release/v1
        with:
          verbose: true
          print-hash: true
```

### Pattern 5: Publishing via API Token (Manual Flow)

For manual publishing outside CI, use a `~/.pypirc` configuration file with an API token instead of a username/password. This is the recommended approach since PyPI deprecated password-based authentication.

```ini
# ~/.pypirc — store in your home directory, permissions 600
[testpypi]
    repository = https://test.pypi.org/legacy/
    # Token starts with pypi-
    password = pypi-AgEIcHlwaS5vcmc.e7Bb...

[pypi]
    repository = https://upload.pypi.org/legacy/
    # Token starts with pypi-
    password = pypi-AgEIcHlwaS5vcmc.x9Yz...
```

Publish commands using the token:

```bash
# Build both wheel and sdist
python -m build

# Verify before upload — fix any issues here
twine check dist/*

# Publish to TestPyPI first (validate install, entry points, metadata)
twine upload --repository testpypi dist/*

# After confirming TestPyPI works, publish to production PyPI
twine upload dist/*
```

---

## Constraints

### MUST DO
- Always use `dynamic = ["version"]` with `hatch-vcs` — never hardcode version numbers in `pyproject.toml` or source files
- Run `twine check dist/*` on every artifact before uploading to any registry
- Publish to TestPyPI first for new packages or major version bumps
- Tag releases with `v` prefix (e.g., `git tag -a v1.2.3 -m "Release 1.2.3" && git push origin v1.2.3`)
- Set `requires-python = ">=X.Y"` to match your minimum supported Python version
- Include both wheel and sdist in every release — users on exotic platforms may need the source distribution
- Define entry points under `[project.scripts]` for every CLI command, not inside `__init__.py`
- Use API tokens (starting with `pypi-`) instead of usernames and passwords for twine authentication
- Include a `LICENSE` file that matches the license declared in `pyproject.toml`
- Set `allow-direct-references = false` unless you have an explicit reason to allow them
- Document optional dependency groups clearly so users know `pip install mypkg[dev,test]` is valid

### MUST NOT DO
- Never publish to PyPI without running tests and twine verification first
- Never commit API tokens, credentials, or secrets to the repository
- Never use hardcoded versions that must be manually incremented on every release
- Never skip TestPyPI publishing for new package names or major version bumps (1.0.0 → 2.0.0)
- Never publish a package whose `long_description` fails twine rendering checks
- Never include test data, `.git` directories, or cache files in the source distribution
- Never use `[tool.setuptools]` alongside `[build-system] hatchling` — choose one build backend
- Never forget to run with `fetch-depth: 0` in CI when using hatch-vcs (tags will not be found)
- Never publish with `--skip-existing` without verifying the upload actually succeeded — silent skips cause corrupted releases
- Never use `setup.py` alongside `pyproject.toml` with hatchling — hatchling does not read setup.py

---

## Output Template

When implementing or auditing a Python package for publishing, produce:

1. **pyproject.toml Audit** — Verify all required `[project]` fields are present and correctly formatted. Confirm `[build-system]` declares `hatchling`. Check that `dynamic = ["version"]` is set with hatch-vcs configured under `[tool.hatch.version]`. List any missing or misconfigured fields.

2. **Distribution Verification Report** — Run `python -m build` and `twine check dist/*`. Document the list of artifacts produced, their sizes, and whether verification passed. Flag any warnings about missing classifiers, license files, or long description rendering issues.

3. **Entry Point Audit** — List all commands defined under `[project.scripts]` and verify each resolves to an existing callable. Test by installing the package in editable mode (`pip install -e .`) and executing each command. Report any broken entry points with the exact resolution path.

4. **CI/CD Pipeline Review** — Evaluate the GitHub Actions workflow (or equivalent). Confirm: checkout fetches all tags, build runs `python -m build`, twine verification passes before upload, TestPyPI publish runs first, and production PyPI uses trusted publishing via `id-token: write`.

5. **Optional Dependencies Matrix** — List each `[project.optional-dependencies]` group with its packages, note any cross-group overlaps, and verify that installing each group individually does not cause conflicts or unexpected transitive pulls.

---

## Related Skills

| Skill | Purpose |
|---|---|
| `coding/python-module-structure` | Organize your package directory layout and `__init__.py` exports before building for distribution |
| `modern-python-development` | Ensure your code follows Python 3.10+ typing, project structure, and tooling best practices that packaging builds on top of |
| `coding/software-delivery-pipelines` | Extend beyond PyPI publishing to Docker images, container registries, and multi-platform deployment workflows |

---

## Live References

> Authoritative documentation links for this skill's domain. The model follows markdown links at load time to resolve external references and inline content.

- [Hatchling Documentation](https://hatch.pypa.io/latest/) — Official build backend docs: configuration, build hooks, targets, plugins
- [hatch-vcs Documentation](https://hatch.pypa.io/latest/version/vcs/) — Git-tag-driven dynamic versioning with PEP 440 compliance
- [Python Packaging User Guide](https://packaging.python.org/en/latest/) — Canonical PyPA guidance on packaging, distribution, and publishing
- [twine Documentation](https://twine.readthedocs.io/en/stable/) — Secure PyPI uploads, verification, and repository configuration
- [PyPI Trusted Publishers (GitHub Actions)](https://docs.pypi.org/trusted-publishers/using-a-publisher/) — OIDC-based authentication for CI/CD publishing without API tokens
- [PEP 621 — Storing Project Metadata in pyproject.toml](https://peps.python.org/pep-0621/) — Specification for project metadata format used by hatchling
- [Python Packaging Authority: Building and Distributing Packages](https://packaging.python.org/en/latest/tutorials/packaging-projects/) — Step-by-step tutorial for creating distributable packages

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…