# Building and Automating a Python Package with Trusted Publishing

There’s a big difference between:

“I wrote a Python script.” and “I built, packaged, versioned, and automated a Python library for PyPI using Trusted Publishing.”

This project started small. I just wanted to understand how Python packages *actually* work.

Not just:

```bash
pip install -e .
```

But:

*   What makes a package installable?
    
*   What actually happens when you run `pip install`?
    
*   How does PyPI verify uploads?
    
*   How do you automate releases properly?
    

* * *

### The Idea Behind the Project

In many projects, paths get messy.

*   Hardcoded directories creep in.
    
*   Relative paths become brittle.
    
*   Boilerplate repeats across repositories.
    

I was building multiple data scraping projects and found myself rewriting the same directory resolution logic again and again.

So I built a small utility package that dynamically resolves:

*   Project root
    
*   Absolute project root
    
*   Resource locations (like WebDriver paths)
    

Simple problem. Clean solution.

I just wanted a reusable library, so I didn’t have to repeat boilerplate code in every scraping project.

* * *

### The Real Goal: Make It Real

I didn’t want this to be just a GitHub repository.

I wanted it to be installable via:

```bash
pip install directionfinder
```

That’s when things got interesting.

This article walks through that journey — step by step.

* * *

### Step 1 — Modern Packaging with `pyproject.toml`

Instead of using the older `setup.py`, I followed **PEP 621** and used `pyproject.toml`. I have both files in my git repo though.

```toml
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "directionfinder"
version = "0.6.1"
description = "Utility package to dynamically resolve project directory paths."
readme = "README.md"
requires-python = ">=3.7"

authors = [
  { name="Aayush Pokharel", email="official@aayushpokharel.com" }
]

license = { text = "MIT" }

classifiers = [
  "Programming Language :: Python :: 3",
  "Operating System :: OS Independent",
]
```

### Build Locally

```bash
pip install build
python -m build
```

Artifacts generated:

```plaintext
dist/
  directionfinder-0.6.1.tar.gz
  directionfinder-0.6.1-py3-none-any.whl
```

That felt satisfying.

But uploading manually using `twine` felt… outdated.

* * *

### Step 2 — Trusted Publishing (No Tokens!)

Instead of storing an API token in GitHub Secrets, I used **Trusted Publishing**.

Trusted Publishing uses **OIDC (OpenID Connect)** to let GitHub prove its identity directly to PyPI.

*   No passwords.
    
*   No API tokens.
    
*   No secrets to rotate.
    

That’s beautiful.

Here’s how the trust chain works:

![https://cdn.hashnode.com/uploads/covers/66817765c6c9e69428c980f6/f2ae8dcc-9a8a-4619-9c92-38a6c4d9cdcf.png](https://cdn.hashnode.com/uploads/covers/66817765c6c9e69428c980f6/f2ae8dcc-9a8a-4619-9c92-38a6c4d9cdcf.png align="middle")

GitHub generates a short-lived identity token.  
PyPI verifies that the repository and workflow match the registered trusted publisher.

**If everything matches → upload allowed.**

* * *

### Step 3 — GitHub Actions Workflow

I created:

```plaintext
.github/workflows/publish.yml
```

```yaml
name: Publish to TestPyPI

on:
  push:
    tags:
      - "v*"

jobs:
  publish:
    runs-on: ubuntu-latest

    permissions:
      id-token: write
      contents: read

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.x"

      - run: |
          python -m pip install --upgrade pip
          pip install build

      - run: python -m build

      - uses: pypa/gh-action-pypi-publish@release/v1
        with:
          repository-url: https://test.pypi.org/legacy/
```

Notice this part:

```yaml
permissions:
  id-token: write
```

This is required for OIDC authentication.

Without it, Trusted Publishing will fail.

* * *

### Step 4 — Version-Based Release Flow

Publishing is triggered by tags:

```bash
git tag v0.5.0
git push origin v0.5.0
```

Behind the scenes:

![](https://cdn.hashnode.com/uploads/covers/66817765c6c9e69428c980f6/21f71f86-a2cf-42af-a299-1d531b7042be.png align="middle")

When it worked for the first time, I just stared at the green checkmark.

It felt earned.

* * *

### The Bugs Along the Way

It wasn’t smooth and I did make some mistakes.

Here’s what broke.

### Workflow Didn’t Trigger

Because tags must be pushed.

Creating a tag locally is not enough:

```bash
git push origin v0.5.0
```

Otherwise, GitHub Actions never runs.

### HTTP 400 Bad Request

That one was painful.

It turned out my Trusted Publisher was registered for:

```plaintext
direction_pkg
```

But my actual project name was:

```plaintext
directionfinder
```

**Mismatch = rejection.**

Lesson learned:

Project name must match exactly across:

*   `pyproject.toml`
    
*   PyPI project name
    
*   Trusted Publisher configuration
    

Even a small mismatch causes failure.

## Version Conflicts

You cannot overwrite a version on PyPI.

If `0.6.0` already exists, you must bump:

```toml
version = "0.6.1"
```

And then:

```bash
git tag v0.6.1
git push origin v0.6.1
```

Version discipline matters.

* * *

### Automatic Attestations (Unexpected Bonus)

Another surprise:

GitHub automatically generated digital attestations using **Sigstore**.

This means:

*   The package build is cryptographically verifiable
    
*   The provenance is recorded in a transparency log
    
*   The supply chain is traceable
    

Modern Python packaging is not just “uploading files.”

It’s about trust.

Here’s the bigger picture:

![](https://cdn.hashnode.com/uploads/covers/66817765c6c9e69428c980f6/462511fe-4edb-4423-aa4d-0fee7397d979.png align="middle")

This is no longer “just a Python script.”

This is CI/CD + supply-chain-aware packaging.

* * *

## Installing from TestPyPI

After successful publishing:

```bash
pip install \
  --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple \
  directionfinder
```

And it works. That feeling is different from running a local script.

> The extra flags in the install command tell `pip` where to look for packages and how to resolve dependencies. The `--index-url https://test.pypi.org/simple/` flag instructs `pip` to use **TestPyPI’s Simple API index** as the primary source for locating the package (`directionfinder` in this case). However, TestPyPI usually does not host common dependencies like `setuptools`, `requests`, or other third-party libraries. That’s why we add `--extra-index-url https://pypi.org/simple`. This flag tells `pip` to fall back to the real PyPI index if a dependency is not found on TestPyPI. In short, `--index-url` defines the main package source, and `--extra-index-url` acts as a secondary lookup location to ensure dependency resolution works smoothly.

* * *

## What This Project Taught Me

*   Packaging is more than code.
    
*   Versioning is a discipline.
    
*   Automation removes human error.
    
*   Trusted Publishing is the future.
    
*   Secure software supply chain matters.
    
*   Debugging CI is part of real engineering.
    

Most importantly:

> Small projects become powerful when treated professionally.

* * *

## Final Thoughts

This started as:

> “I want to understand how Python packages work.”

It ended as:

> “I built an automated, secure, versioned, supply-chain-aware release pipeline.”

And honestly, that shift in mindset is the real win.

If you're learning Python packaging:

Don’t stop at local builds.

**Automate it.  
Break it.  
Fix it.  
Secure it.  
Understand it.**

That’s where growth happens. You can find my git repo for this project at:  
[github.com/AayushPokharel/directions\_pkg](https://github.com/AayushPokharel/directions_pkg)
