§1 — Why this is a prerequisite rather than a nicety

Measured: pip index versions reckon-plans returns no matching distribution. So a consuming repository's CI can only install reckon from a git URL, and that forces a choice between two bad options. An unpinned ref means any reckon change can break a consumer's build with no signal and no release note. A pinned SHA means every reckon change requires a commit in every consuming repository to adopt it.

Federated publication multiplies that cost by the number of participating repositories, which is the one genuine argument against the federated shape. A released version on PyPI converts it into ordinary dependency management: consumers write reckon-plans>=X.Y, adopt on their own schedule, and a breaking change is announced by a major version rather than discovered by a red build.

§2 — What the package already has, and the four gaps

Packaging is closer than the absence from PyPI suggests. pyproject.toml already declares the hatchling backend with hatch-vcs for versioning, the distribution name, a dynamic version, description, readme, requires-python, authors, an MIT license with license-files, keywords, runtime dependencies and the reckon console script. LICENSE and README.md both exist.

GapConsequence if shipped as-is
No [project.urls]The PyPI page carries no repository or documentation link, so a reader cannot reach the source
One classifier (license only)Not discoverable by Python version, audience, topic or maturity; PyPI shows it as uncategorised
No publish workflowRelease is a manual local build, which is where credentials leak and unreproducible artifacts come from
hatch-vcs plus a shallow CI checkoutThe version is derived from git tags, and actions/checkout defaults to depth 1 — so the build either fails or produces a junk version

The last one is the trap worth stating explicitly because it fails late: the workflow runs, the build appears to succeed, and the artifact carries a version derived from no tag. The fix is one line — fetch-depth: 0 — and it is invisible until someone inspects the published version string.

§3 — Trusted publishing, not an API token

Publish through PyPI's OIDC trusted-publisher flow rather than a long-lived API token in repository secrets. The workflow requests a short-lived identity token from GitHub, PyPI verifies it against a publisher registered as this repository plus this workflow filename plus a named environment, and no credential is ever stored. A leaked secret cannot be leaked if it does not exist.

That registration is a one-time action on PyPI's side and is the only step that cannot be automated from here, because it requires an authenticated session as the project owner.

Deliverable: the four gaps closed; a release workflow triggered by a version tag that checks out with full history, builds a wheel and sdist, and publishes via trusted publishing with id-token: write; and a TestPyPI dry run before the first real upload. Done when a fresh environment can pip install reckon-plans from PyPI, the installed reckon --version matches the git tag exactly, and the console script runs reckon build on a scratch project without the repository present.

That last assertion matters more than it looks: it proves the distribution ships the UI assets and shared CSS it needs, rather than reaching for files that only exist in a checkout. A wheel that works only where the source tree is also present would pass a naive import test and fail every consumer.

§4 — Versioning commitment

Once consumers pin a version, the version numbers become a contract rather than a label. Two commitments follow, and they are cheap to make now and expensive to retrofit: the plan HTML format and the MCP tool surface are the public interfaces, so a breaking change to either takes a major version; and the SPA assets shipped in the wheel are part of the distribution, so a consumer upgrading reckon gets new UI, which is exactly why the static build must be reproducible from a pinned version.

§ Decisions

What versioning scheme does the published package commit to?

Locked by the lead 2026-08-24. The plan HTML format and the MCP tool surface are the declared public interface, so a breaking change to either takes a major version. That is what lets a consumer write reckon-plans>=X.Y and adopt on its own schedule, which is the entire reason for leaving git refs behind - calendar versioning would force exact pins and a re-test on every bump, reintroducing most of the N-repositories-to-bump cost that publishing is meant to remove. Accepted cost: a judgement call per release about whether a change is breaking, which the two named interfaces make tractable.

§ Followups

Close the four packaging gaps and publish through trusted publishing

Add [project.urls] and real classifiers, register the PyPI trusted publisher, and add a tag-triggered release workflow that checks out with fetch-depth: 0 so hatch-vcs can see the tag. TestPyPI first. The acceptance that matters is not an import test but running reckon build from the installed wheel on a scratch project with no checkout present, which proves the distribution actually ships its UI assets.

/reckon-ship reckon:pypi-release

packaging-metadata landed — commit dff9e38; gate suite-focused passed; measure: Project-URL entries 0 → 4, classifiers 1 → 7, focused test 1 passed / 0 failed, wheel 486,535 B and sdist 1,530,480 B both built. Artifacts pyproject.toml, tests/test_packaging_metadata.py. The test asserts against the built wheel METADATA rather than the source file, so it fails if packaging drops the fields in transit.

release-workflow landed — commit 6616c6d; gate structural-contract passed; measure: 6 passed, 0 failed. .github/workflows/release.yml carries 3 jobs, an exact semantic-version tag filter (line 5), fetch-depth: 0 (line 18) which is the late-failing trap §2 names, TestPyPI with its own OIDC environment (lines 29-48), and a PyPI job gated on TestPyPI succeeding (lines 50-66). No password, API token, or secret is referenced anywhere in the workflow.

version-provenance landed — commit b9732eb; gate full-file suite passed; measure: tests/test_build.py 16 passed, 0 failed in 4.82 s, ruff clean. This node did not exist when the wave was cut. The prior-art scout found that the wheel self-containment proof §3 asks for already existed at tests/test_build.py:427, while the other half of the same acceptance was broken: reckon/__init__.py looked up version("reckon") against a distribution named reckon-plans, so an installed wheel reported the "dev" fallback, and no reckon --version existed at all. The plan's stated done-when could not have passed at any point. Lookup now names the real distribution and narrows its except clause to PackageNotFoundError, and the installed-wheel fixture asserts exact equality from outside the checkout.

Authority boundary reached, and it is the one §3 predicted. Registering the trusted publisher needs an authenticated session as PyPI project owner, and the first upload is irreversible — a version number can be yanked but never reused. Lead selected 0.2.0 as the first released version, following the existing rc line to its release; sub-1.0 matches an interface this same sprint is about to change, since declaring 1.0 now would force 2.0.0 on landing the tag field and project-qualified sprint refs under the locked strict-semver contract. Remaining, both owner-only: register release.yml for the testpypi and pypi environments, then push v0.2.0. Full record: execution evidence.

release-dry-run-trigger landed — commit 026c8cd; gate structural-contract passed; measure: 8 passed, 0 failed (the original 6 plus 2 new). Found by reading the merged workflow against this section's own words. §3 asks for a TestPyPI dry run before the first real upload, but the workflow triggered only on a version tag and chained TestPyPI into PyPI — so the only rehearsal available was spending the real version number. workflow_dispatch now takes a testpypi-only input defaulting to true, and the PyPI job cannot be reached while it is set.

Regression verdict: no existing test changed state. An absolute suite count could not settle this — the first run returned 45 non-passing outcomes, all traced to sandbox restrictions, and the worker said so rather than claiming green. So the question became a difference: the same suite at the pre-wave base (1937 passed / 34 failed / 10 errors) and at the integrated revision (1944 passed / 35 failed / 10 errors), invoking the repository interpreter directly because the shared uv cache is mounted read-only in the worker sandbox and kills uv run before pytest collects. All 34 pre-wave failing ids still fail, all 10 error ids still error, none recovered. Exactly one non-passing id is new — the wave's own test_built_wheel_carries_discovery_metadata, whose traceback shows its internal uv build hitting that same read-only cache rather than an assertion mismatch; its authoring worker ran it green with a working cache.

Trusted publishers registered by the lead on both PyPI and TestPyPI. That clears the half of the authority boundary that could never be automated from here. One owner-only action remains and it is irreversible: pushing v0.2.0. A rehearsal is now available first — run the workflow manually with testpypi-only left at its default, which publishes to TestPyPI and cannot reach PyPI.