§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.
Gap
Consequence 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 workflow
Release is a manual local build, which is where credentials leak and unreproducible artifacts come from
hatch-vcs plus a shallow CI checkout
The 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.py16 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.
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. Artifactspyproject.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.ymlcarries 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.py16 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 attests/test_build.py:427, while the other half of the same acceptance was broken:reckon/__init__.pylooked upversion("reckon")against a distribution namedreckon-plans, so an installed wheel reported the"dev"fallback, and noreckon --versionexisted 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 toPackageNotFoundError, 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.ymlfor thetestpypiandpypienvironments, then pushv0.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_dispatchnow takes atestpypi-onlyinput 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 runbefore 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 owntest_built_wheel_carries_discovery_metadata, whose traceback shows its internaluv buildhitting 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 withtestpypi-onlyleft at its default, which publishes to TestPyPI and cannot reach PyPI.