← Back to list

How to publish a Python package with a manual GitHub Actions release workflow

Publishing a Python library makes your code reusable and discoverable. This guide walks through a minimal, reliable workflow to prepare…

Ata Can Yaymacı · 2026-02-21 23:06 · 6 claps · 3.9 min read
#pypi #python #python-packaging #github-workflow
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud 🔓 · Open Source 📚 · Books & Reading

How to publish a Python package with a manual GitHub Actions release workflow

Publishing a Python library makes your code reusable and discoverable. This guide walks through a minimal, reliable workflow to prepare, build, and publish a package to PyPI, plus good practices for CI, versioning, and releases.

1. Prerequisites

  • Python 3.10+ installed.
  • A project with a clear package layout and README.md.
  • A PyPI account (create at https://pypi.org/).
  • Git repository (preferably on GitHub).

2. Project layout (recommended)

Keep a simple layout:

  • myproject/ (package)
  • tests/
  • pyproject.toml
  • README.md
  • LICENSE
  • setup.cfg (optional)

3. Prepare metadata with pyproject.toml

A minimal pyproject.toml using setuptools + wheel or flit/poetry. Below is a concise pyproject.toml for setuptools with declarative metadata.

[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "my-cool-package"
version = "0.1.0"
description = "Short description of my package."
readme = "README.md"
requires-python = ">=3.10"
license = {text = "MIT"}
authors = [{name = "Your Name", email = "you@example.com"}]
classifiers = [
  "Programming Language :: Python :: 3",
  "License :: OSI Approved :: MIT License",
  "Operating System :: OS Independent"
]

[project.urls]
"Homepage" = "https://github.com/youruser/my-cool-package"
"Repository" = "https://github.com/youruser/my-cool-package"

4. Write tests and CI

  • Add tests under tests/
  • Create a GitHub Actions workflow to run pytest, linters, and format checks on pull requests.
  • Ensure CI installs the package in editable mode so imports work.
pip install -r requirements.txt && pip install -e

5. Local checks before publishing

  • Format and lint: run black, isort, ruff.
  • Run tests: pytest -q — cov=yourpkg — cov-report=xml:coverage.xml
  • Check packaging locally: build a sdist and wheel, then inspect.
python -m pip install --upgrade build twine
python -m build

6. Validate distributions

python -m twine check dist/*

7. Create a PyPI API token

  • Go to https://pypi.org/manage/account/ and create an API token scoped to the project or to all projects.
  • Store the token securely (use GitHub repository secret PYPI_API_TOKEN for CI).

8. Upload to Test PyPI first (recommended)

Install and test the package from test.pypi.org to ensure everything works.

python -m twine upload --repository testpypi dist/*
# or interactive, specify username __token__ and token as password

9. Publish to PyPI

python -m twine upload dist/*

10. Versioning and Git tags

git tag v0.1.0
git push --tags

11. Automate publishing with GitHub Actions

  • Add a workflow that builds on tag push (e.g., on: push: tags: [‘v..*’]).
  • In the workflow, install dependencies, build, and upload using twine with PYPI_API_TOKEN secret.
# yaml
name: Release (manual)

on:
  workflow_dispatch:
    inputs:
      version:
        description: 'Version to release (semver), e.g. 0.1.1'
        required: true
        type: string
      repository:
        description: 'Publish target: pypi or testpypi'
        required: true
        type: choice
        options:
          - pypi
          - testpypi
      changelog_from:
        description: 'Git ref to start changelog from (optional, default previous tag)'
        required: false
        type: string

permissions:
  contents: write
  packages: write
  issues: write

concurrency:
  group: release-${{ github.ref }}
  cancel-in-progress: false

jobs:
  release:
    name: Create release and publish
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
          persist-credentials: true

      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.11'

      - name: Show inputs
        run: |
          echo "inputs: version=${{ github.event.inputs.version }} repository=${{ github.event.inputs.repository }}"

      - name: Bump version in pyproject.toml
        id: bump
        run: |
          set -euo pipefail
          NEW_VERSION="${{ github.event.inputs.version }}"
          python scripts/bump_version.py "$NEW_VERSION"

          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add pyproject.toml
          git commit -m "chore(release): bump version to ${NEW_VERSION}" || echo "no changes to commit"
          # push current branch (safe: fetch-depth 0 above)
          CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
          git push origin "HEAD:${CURRENT_BRANCH}" || true
          echo "bumped=${NEW_VERSION}" >> $GITHUB_OUTPUT

      - name: Create annotated tag
        id: tag
        run: |
          set -e
          TAG="v${{ github.event.inputs.version }}"
          # avoid creating existing tag
          if git rev-parse "$TAG" >/dev/null 2>&1; then
            echo "Tag $TAG already exists"
          else
            git tag -a "$TAG" -m "Release $TAG"
            git push origin "$TAG"
          fi
          echo "tag=$TAG" >> $GITHUB_OUTPUT

      - name: Build distributions
        env:
          VERSION: ${{ github.event.inputs.version }}
        run: |
          set -euo pipefail
          python -m pip install --upgrade pip build twine
          python -m build --sdist --wheel

          # verify built artifacts contain expected version
          python scripts/check_dist_version.py "${{ github.event.inputs.version }}"

      - name: Validate metadata and long description
        run: |
          set -e
          python -m twine check dist/* || true

      - name: Publish to PyPI / TestPyPI
        env:
          TWINE_USERNAME: __token__
        run: |
          set -euo pipefail
          REPO="${{ github.event.inputs.repository }}"

          if [ "$REPO" = "testpypi" ]; then
            if [ -z "${{ secrets.TEST_PYPI_API_TOKEN }}" ]; then
              echo "ERROR: TEST_PYPI_API_TOKEN secret is not set" >&2
              exit 1
            fi
            export TWINE_PASSWORD="${{ secrets.TEST_PYPI_API_TOKEN }}"
            python -m pip install --upgrade pip
            pip install twine
            python -m twine upload --repository-url https://test.pypi.org/legacy/ dist/*
          else
            if [ -z "${{ secrets.PYPI_API_TOKEN }}" ]; then
              echo "ERROR: PYPI_API_TOKEN secret is not set" >&2
              exit 1
            fi
            export TWINE_PASSWORD="${{ secrets.PYPI_API_TOKEN }}"
            python -m pip install --upgrade pip
            pip install twine
            python -m twine upload dist/*
          fi

      - name: Generate changelog
        id: changelog
        run: |
          set -e
          if [ -n "${{ github.event.inputs.changelog_from }}" ]; then
            PREV="${{ github.event.inputs.changelog_from }}"
          else
            PREV=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || true)
          fi

          if [ -n "$PREV" ]; then
            git log --pretty=format:'- %s (%an)' "$PREV"..HEAD > changelog.md || true
          else
            git log --pretty=format:'- %s (%an)' > changelog.md || true
          fi

          # write multiline output safely
          echo 'body<<EOF' >> "$GITHUB_OUTPUT"
          if [ -f changelog.md ]; then
            awk '{ print " " $0 }' changelog.md >> "$GITHUB_OUTPUT" || true
          fi
          echo >> "$GITHUB_OUTPUT"
          echo 'EOF' >> "$GITHUB_OUTPUT"

      - name: Create GitHub Release
        uses: actions/create-release@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          tag_name: ${{ steps.tag.outputs.tag }}
          release_name: ${{ steps.tag.outputs.tag }}
          body: ${{ steps.changelog.outputs.body }}

      - name: Cleanup
        run: |
          rm -rf dist build *.egg-info changelog.md

12. Post-release checklist

  • Verify package on PyPI and install it locally: pip install my-cool-package.
  • Update documentation and changelog.

Bonus: Best Practices

  • Use CI to block broken releases.
  • Keep README.md as the long description (use twine check to validate).
  • Use semantic versioning.
  • Automate publishing from tags, never from unreviewed branches.
  • Keep secrets in CI provider, not in repository.

메타데이터
post_id
fa1dc5cb7b56
slug
how-to-publish-a-python-package-with-a-manual-github-actions-release-workflow-fa1dc5cb7b56
url
https://medium.com/@atacanymc/how-to-publish-a-python-package-with-a-manual-github-actions-release-workflow-fa1dc5cb7b56
canonical_url
https://medium.com/@atacanymc/how-to-publish-a-python-package-with-a-manual-github-actions-release-workflow-fa1dc5cb7b56
author_url
https://medium.com/@atacanymc
status
ok
fetched_at
2026-06-09 15:37:30