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:
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<version> · 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] namethe-loop→the-loopy-one; add troveclassifiersand anIssuesproject 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 kind Value Surface distribution (PyPI) the-loopy-onepip install the-loopy-oneimport package the_loopimport the_loopconsole script the-loopthe-loop --help
2. Lockfile — uv.lock
- Responsibility: reproducibility (RULE: committed lock, no drift).
- Change: re-locked so the member renames
the-loop→the-loopy-one(uv lock). CI'suv syncthen 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), andupdate_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) andworkflow_dispatch(manual re-run).concurrency: releaseserialises runs. releasejob (permissions: contents: write; skipped on the self-pushedbump:commit): checkoutfetch-depth: 0→astral-sh/setup-uv@v5→ git identity → runcz bump --yes --changelog, classifying the exit code:0→ bumped: readversion, set outputreleased=true.21/3(NoneIncrementExit / NoCommitsFoundError) →released=false(clean no-op).- anything else → fail the job. When released:
git push --follow-tags origin HEAD:main→uv build --package the-loopy-one→gh release create v<version> --generate-notes --verify-tag→ uploaddist/artifact (if-no-files-found: error).
publish-pypijob (if: needs.release.outputs.released == 'true'):needs: release,environment: { name: pypi, url: https://pypi.org/project/the-loopy-one/ },permissions: id-token: write→ downloaddist/→pypa/gh-action-pypi-publish@release/v1(nopassword/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
releasejob pushes tomain; protectedmainmust allowgithub-actions[bot]to bypass (or use a bot PAT).GITHUB_TOKEN-authenticated pushes don't recursively triggeron: 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 bumpexits21/3; the job setsreleased=falseand every downstream step (push, build, publish) is skipped — a clean no-op (R3). cz bumpreal 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_TOKENpushes don't re-triggeron: push) and an explicitif: !startsWith(…, 'bump:')guard. - Empty build output:
upload-artifactwithif-no-files-found: errorfails 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.
| Requirement | Verification |
|---|---|
| R1 | uv 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. |
| R2 | Workflow parses; publish-pypi has environment: pypi + id-token: write and no token reference; real publish on first merge to main. |
| R3 | cz 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. |
| R4 | uv lock re-locks to the-loopy-one; uv sync + make check (existing gates) stay green. |
| R5 | decision-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] namechanges; 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
mainwith afeat/fix/breaking commit publishes; thepypienvironment still gates the upload and can carry required reviewers. Trade-off accepted: the release job must push tomain(protected-branch bypass required). - Future-proofing (answers issue #21 Q2): the repo is already a
uvworkspace 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 sharedthe-loopy-oneprefix 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).