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:
| Workflow | Trigger | What it produces | Where it goes | Pinning |
|---|---|---|---|---|
| Build | Every push to main + PRs | sdist + wheel | Nowhere (validation only) | N/A |
| Release | Manual dispatch | sdist + wheel, changelog, docs tarball | Forgejo Packages + Forgejo Releases | Version 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
- Checkout the commit
uv build→ producesdist/*.tar.gz(sdist) anddist/*.whl(wheel)- Smoke test — pipe sample JSON through
uv run mercuryto 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.dev0on 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
- Resolve version — bumps patch/minor/major from the latest Forgejo release tag, or accepts a specific
vX.Y.Z - Build changelog — runs
towncrier buildto consumedocs/changelog.d/fragments intoCHANGELOG.md - Build docs — produces a docs site tarball via Dagger
- Build Python package —
uv buildwith the release version stamped intopyproject.toml - Publish to Forgejo Packages — uploads sdist and wheel to the Forgejo PyPI registry
- Create Forgejo release — tags the release, attaches sdist, wheel, and docs tarball as release assets with changelog notes
- Commit changelog — pushes the towncrier-updated
CHANGELOG.mdback 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.tomlversion is only stamped at build time; on main it stays at the0.0.0.dev0sentinel. - 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
| System | Role |
|---|---|
| Forgejo Packages | PyPI-compatible registry for our packages. Releases are published here |
| Forgejo Releases | Human-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 = trueOr 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.orgWith this configured, no special flags are needed:
uvx mercury --version # runs latest release from Forgejo Packages
uv pip install mercury # installs from Forgejo PackagesWhat “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:
- Register a PyPI trusted publisher — configure the OIDC identity of the Forgejo workflow as a trusted publisher on pypi.org
- Swap the publish URL — change
--publish-urlfrom the Forgejo Packages URL tohttps://upload.pypi.org/legacy/ - 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.
Related
- tooling — Project layout, mise tasks, key commands
- agent-change-process — How to classify and execute changes to these workflows