Skip to content

Design: Publish the-loop to PyPI

Phase 2 of 3 (requirements → design → tasks). Derives from the approved requirements. Durable choices are logged in docs/decisions/decision-019.md.

Overview

Publish the cli workspace member to PyPI as the distribution the-loopy-one using GitHub Actions Trusted Publishing (OIDC) — no stored token — automatically on merge to main, with the version derived from Conventional Commits (owner request, PR #22). A two-job workflow: release runs cz bump (compute version → rewrite version files → tag → push to main → GitHub Release → uv build); publish-pypi, gated by the pypi environment, uploads the artifacts over OIDC. This satisfies R1–R5; the naming rationale, the semantic-release choice, and the future-proofing convention are recorded in decision-019.

Architecture

Existing surfaces (commitizen, uv, the workspace member) plus one new workflow:

mermaid
flowchart TD
    merge["merge PR to main<br/>(squash commit = PR title, Conventional Commits)"] --> push[["push: branches [main]"]]
    push --> bump

    subgraph gha["release.yml (GitHub Actions)"]
      bump["job: release<br/>cz bump (feat/fix/BREAKING -> semver)<br/>rewrite .cz.toml + cli/pyproject.toml<br/>tag v&lt;version&gt; · push main · gh release<br/>uv build --package the-loopy-one"]
      bump -->|released == true<br/>upload dist/ artifact| pub["job: publish-pypi<br/>environment: pypi<br/>permissions: id-token: write"]
    end

    bump -.->|no releasable commit<br/>(cz exit 21/3)| noop["no-op: publish nothing"]
    pub -->|OIDC id-token| pypi[("PyPI project<br/>the-loopy-one")]

Trust is established once, out of band: PyPI's Trusted Publisher binds MadaraUchiha-314/the-loop + workflow release.yml + environment pypi to the the-loopy-one project, so the publish-pypi job authenticates with a short-lived OIDC token instead of a secret. The trigger changed from a manual Release to push: main, but the workflow filename and environment — the only things the publisher identity binds to — are unchanged.

Components & interfaces

1. Distribution metadata — cli/pyproject.toml

  • Responsibility: declare the published distribution.

  • Change: [project] name the-loopthe-loopy-one; add trove classifiers and an Issues project URL. [tool.hatch.build.targets.wheel] packages = ["the_loop"] is unchanged — it names the import package, independent of the distribution name, so the console script (the-loop) and import (the_loop) are unaffected.

  • Three names, deliberately distinct:

    Name kindValueSurface
    distribution (PyPI)the-loopy-onepip install the-loopy-one
    import packagethe_loopimport the_loop
    console scriptthe-loopthe-loop --help

2. Lockfile — uv.lock

  • Responsibility: reproducibility (RULE: committed lock, no drift).
  • Change: re-locked so the member renames the-loopthe-loopy-one (uv lock). CI's uv sync then resolves cleanly.

3. Versioning config — .cz.toml

  • Responsibility: commitizen owns the canonical version and the bump rules.
  • Change: add version_files = ["cli/pyproject.toml:^version = "] so a bump rewrites the package version (not just .cz.toml), and update_changelog_on_bump = false (the changelog is generated by the release job with --changelog, not on local bumps).

4. Release workflow — .github/workflows/release.yml (new)

  • Triggers: push: branches: [main] (releases) and workflow_dispatch (manual re-run). concurrency: release serialises runs.
  • release job (permissions: contents: write; skipped on the self-pushed bump: commit): checkout fetch-depth: 0astral-sh/setup-uv@v5 → git identity → run cz bump --yes --changelog, classifying the exit code:
    • 0 → bumped: read version, set output released=true.
    • 21/3 (NoneIncrementExit / NoCommitsFoundError) → released=false (clean no-op).
    • anything else → fail the job. When released: git push --follow-tags origin HEAD:mainuv build --package the-loopy-onegh release create v<version> --generate-notes --verify-tag → upload dist/ artifact (if-no-files-found: error).
  • publish-pypi job (if: needs.release.outputs.released == 'true'): needs: release, environment: { name: pypi, url: https://pypi.org/project/the-loopy-one/ }, permissions: id-token: write → download dist/pypa/gh-action-pypi-publish@release/v1 (no password/token → it uses Trusted Publishing).
  • Contract with PyPI: the file name (release.yml), the environment (pypi), and the owner/repo MUST match the pre-registered Trusted Publisher. They do — unchanged by the trigger switch.
  • Operational note: the release job pushes to main; protected main must allow github-actions[bot] to bypass (or use a bot PAT). GITHUB_TOKEN-authenticated pushes don't recursively trigger on: push, so the release loop terminates.

UI/UX design

N/A — this is CLI/infra packaging with no user-facing visual surface.

Data models

None. The only structured change is pyproject.toml/uv.lock metadata (above).

Error handling

  • No releasable commits: cz bump exits 21/3; the job sets released=false and every downstream step (push, build, publish) is skipped — a clean no-op (R3).
  • cz bump real failure: any other non-zero exit fails the job with ::error::, before any push/build/publish.
  • Recursion: the job's own bump: push is filtered both by GitHub (GITHUB_TOKEN pushes don't re-trigger on: push) and an explicit if: !startsWith(…, 'bump:') guard.
  • Empty build output: upload-artifact with if-no-files-found: error fails fast.
  • Duplicate version: can't occur under normal flow (each merge computes a fresh version); PyPI's immutability is the backstop.
  • Protected main: if the bump push is rejected by branch protection, the job fails visibly (nothing is half-published — the push precedes build/publish).

Testing strategy

This is CI/infra, so "tests" are local build/packaging verification plus a guarded first real Release (evidence in execution-log.md). No new unit/integration Python tests are warranted (no runtime code changed); existing cli tests must stay green.

RequirementVerification
R1uv build --package the-loopy-one produces the_loopy_one-<v>.tar.gz + .whl; wheel METADATA Name: the-loopy-one, contains the_loop/, entry point the-loop = the_loop.__main__:main.
R2Workflow parses; publish-pypi has environment: pypi + id-token: write and no token reference; real publish on first merge to main.
R3cz bump --yes exercised locally: computes 0.1.0 → 0.2.0 (feat) and rewrites both .cz.toml and cli/pyproject.toml (verified, then reverted); a docs-only history yields exit 21 → no-op.
R4uv lock re-locks to the-loopy-one; uv sync + make check (existing gates) stay green.
R5decision-019 records scope + semantic-release choice + future-proofing convention.

Trade-offs & decisions

Logged durably in docs/decisions/decision-019.md. In brief:

  • Distribution-only rename. Only [project] name changes; import/script keep the natural names — a PyPI collision shouldn't leak into user ergonomics.
  • Trusted Publishing over a stored token. OIDC removes a standing credential; the Trusted Publisher was already registered by the issue author.
  • Semantic auto-release on merge (owner request, PR #22), engine = commitizen (already adopted, decision-008) rather than a second release tool. Every merge to main with a feat/fix/breaking commit publishes; the pypi environment still gates the upload and can carry required reviewers. Trade-off accepted: the release job must push to main (protected-branch bypass required).
  • Future-proofing (answers issue #21 Q2): the repo is already a uv workspace with a virtual (non-package) root and named members. Convention going forward: one distribution per publishable member (uv build --package <name>, added to a release matrix), a shared the-loopy-one prefix for siblings, and PEP 420 implicit namespace packages if/when the import surface is split. Nothing is split now (YAGNI); the workflow is written so a second package is additive.

Open questions

None — see requirements.md (all resolved).

Released under the MIT License.