Build & Release Pipeline

How to build, publish, and release the mercury package. The pipeline is designed so that adding PyPI trusted publishing is the only remaining step to go fully public.

Overview

There are two workflows, each with a distinct purpose:

WorkflowTriggerWhat it producesWhere it goesPinning
BuildEvery push to main + PRssdist + wheelNowhere (validation only)N/A
ReleaseManual dispatchsdist + wheel, changelog, docs tarballForgejo Packages + Forgejo ReleasesVersion tag (vX.Y.Z)

Both workflows produce sdist and wheel using uv build. The difference is intent: build validates that the package is producible; release publishes it.

Build Workflow

Goal: Every commit that lands on main (and every PR) builds and smoke-tests the package, confirming the code produces a valid sdist and wheel.

File: .forgejo/workflows/build.yaml

What it does

  1. Checkout the commit
  2. uv build → produces dist/*.tar.gz (sdist) and dist/*.whl (wheel)
  3. Smoke test — pipe sample JSON through uv run mercury to confirm the package works

Build artifacts are not published anywhere. They exist only to prove the build passed.

Design decisions

  • Validation only, no publishing: PyPI-compatible registries are version-indexed — you can’t meaningfully push 0.0.0.dev0 on every commit without version collisions. Rather than stamp synthetic versions on CI builds, we keep it simple: the build workflow validates, the release workflow publishes.
  • Both sdist and wheel: Follows PyPI convention. Wheels install without a build step; sdist is the canonical source archive. For a pure-Python package the cost of producing both is zero. Building both in CI confirms neither is broken.
  • Runs on PRs too: PR builds catch breakage before merge. Same steps, same validation.

Release Workflow

Goal: On manual dispatch, produce a versioned release with changelog, documentation, and publishable Python packages.

File: .forgejo/workflows/release.yaml

What it does

  1. Resolve version — bumps patch/minor/major from the latest Forgejo release tag, or accepts a specific vX.Y.Z
  2. Build changelog — runs towncrier build to consume docs/changelog.d/ fragments into CHANGELOG.md
  3. Build docs — produces a docs site tarball via Dagger
  4. Build Python packageuv build with the release version stamped into pyproject.toml
  5. Publish to Forgejo Packages — uploads sdist and wheel to the Forgejo PyPI registry
  6. Create Forgejo release — tags the release, attaches sdist, wheel, and docs tarball as release assets with changelog notes
  7. Commit changelog — pushes the towncrier-updated CHANGELOG.md back to main

Design decisions

  • Manual dispatch only: Releases are intentional, not automatic. The human decides when a version ships.
  • Version resolution from tags: No version file to keep in sync — the workflow reads the latest release tag and bumps from there. The pyproject.toml version is only stamped at build time; on main it stays at the 0.0.0.dev0 sentinel.
  • Forgejo Packages as the PyPI registry: Forgejo has a built-in PyPI-compatible package registry. Publishing there means the package is installable with standard Python tooling. This is the same publishing path that PyPI trusted publishing would use — only the URL and auth method change.
  • Forgejo Releases for human browsing: The Releases tab is the canonical answer to “what is the latest version?” It includes docs tarballs and changelog notes alongside the Python distfiles. Packages tab has only Python artifacts.
  • Docs tarball as release asset: The docs site is a static build (Quartz via Dagger). Attaching it to the release makes it deployable from any release without rebuilding.
  • Changelog committed back to main: Towncrier consumes fragment files and updates CHANGELOG.md. This commit is pushed with [skip ci] to avoid re-triggering the build workflow.

Package registry architecture

SystemRole
Forgejo PackagesPyPI-compatible registry for our packages. Releases are published here
Forgejo ReleasesHuman-facing release page. Versioned artifacts (sdist, wheel, docs) attached as downloadable files alongside changelog
devpi (pypi.ops.eblu.me)Pull-through cache of public PyPI only. Not a publish target for our packages

devpi serves a single purpose: caching upstream PyPI packages so that builds don’t depend on pypi.org availability. Our own packages are published exclusively to Forgejo Packages.

Developer workstation setup

To install packages from Forgejo Packages, configure uv with two indexes. Forgejo must be checked before public PyPI to prevent dependency confusion with same-named upstream packages (uv’s default first-index strategy stops at the first index that has a given package name):

# uv.toml or [tool.uv] in pyproject.toml
[[index]]
name = "forgejo"
url = "https://forge.eblu.me/api/packages/eblume/pypi/simple/"
 
[[index]]
name = "pypi"
url = "https://pypi.org/simple/"
default = true

Or equivalently via environment variables:

export UV_INDEX="forgejo=https://forge.eblu.me/api/packages/eblume/pypi/simple/"
# UV_DEFAULT_INDEX can optionally point to a PyPI mirror/cache instead of pypi.org

With this configured, no special flags are needed:

uvx mercury --version             # runs latest release from Forgejo Packages
uv pip install mercury            # installs from Forgejo Packages

What “one step from PyPI” means

The pipeline already does everything PyPI requires:

  • Produces standards-compliant sdist and wheel via uv build + hatchling
  • Publishes to a PyPI-compatible registry (Forgejo Packages) using uv publish
  • Uses semver tags for release versions
  • Maintains a changelog

To publish to PyPI, the only changes needed are:

  1. Register a PyPI trusted publisher — configure the OIDC identity of the Forgejo workflow as a trusted publisher on pypi.org
  2. Swap the publish URL — change --publish-url from the Forgejo Packages URL to https://upload.pypi.org/legacy/
  3. Remove explicit credentials — trusted publishing uses short-lived OIDC tokens, no static credentials

No structural changes to the pipeline. No new workflow steps. The build, test, and publish flow is already in place.

Important: There is an existing mercury package on public PyPI that we do not own. We will never actually publish there — it would fail on name collision. This is by design: if any tooling ever resolves a mercury version from upstream PyPI (rather than our private registry), that’s an immediate signal of a misconfigured index or dependency confusion. The name collision serves as a supply chain canary.