ADR-027: Published to PyPI via trusted publishing; the scaffold ships inside the package

Status Active · Filed 2026-08-04 · Issue #3 · Influenced by ADR-009 · ADR-021

Context

Distribution was the open half of #3: the layout question resolved into the read/write boundary (ADR-021), and pip install was named early as the path that makes the vendored-deadweight worry moot — an installed Luria carries the scaffold and the tool, not the project’s history. A trusted publisher is now registered on PyPI, expecting the package from a publish.yml workflow in a pypi environment.

Building the wheel surfaced that the existing packaging worked by accident. The setuptools config declared the template as parent-relative package data (luria = ["../template/**/*"]), which landed template/ at the wheel root — installing a bare, unnamespaced template/ directory into site-packages, where it would collide with any other package shipping one. luria init then found it only because Path(__file__).parent.parent from inside site-packages happens to be site-packages. Functional, and wrong in the way that breaks on the first neighbour.

Decision

Trusted publishing, environment-gated. publish.yml builds on a GitHub release (or manual dispatch); the publish job runs in the pypi environment with id-token: write and nothing else — the OIDC identity is the whole credential. No API token exists to leak, rotate, or forget. Build and publish are separate jobs so the environment gate covers the smallest surface and the artifact that ships is byte-identical to the one tested.

The scaffold ships inside the package. The wheel carries the template at luria/template/ (hatchling force-include mapping the top-level directory in), and luria init resolves the packaged location first, falling back to the repository top level in a checkout. Both truths hold at once: an install is self-contained and collision-free; a visitor to the repository still meets template/ at the top level, which ADR-021 put there on purpose.

The smoke test is the release gate. Before anything ships, the workflow cold-installs the built wheel into a fresh venv and runs the full first-user path — init → index → journal new → index → lint — in an empty directory. This is the only configuration that exercises the packaged template path; the dogfooding repo structurally cannot (ADR-009’s limit, met before: a checkout always has the top-level fallback). Fired before trusting, per DP-6 — and it immediately caught the scaffolded CLAUDE.md’s illustrative wikilinks leaking as day-one warnings in a fresh project.

The sdist is a build input, not a browsing surface. It carries the package, the template, the tests and the metadata — not the record. The record’s home is the repository; shipping ~300 KB of Luria’s own history in every pip download would be the deadweight #3 worried about, relocated.

Alternatives considered

  • API token in a repository secret. The thing trusted publishing exists to retire: a long-lived credential that can leak in a log, needs rotation nobody remembers, and grants more than one workflow needs.
  • Publish on tag push instead of release. Nearly equivalent; the release object is kept as the trigger because it carries notes and makes “what shipped” a browsable artifact — and workflow_dispatch covers the escape hatch either way.
  • Symlink luria/template → ../template to satisfy setuptools without changing backends. Works on the platforms it works on; a checkout on Windows without symlink privileges quietly gets a broken package dir. A build-time mapping has no runtime footprint in the repo.
  • Keep setuptools and accept the wheel-root template. Functional today, and a name collision away from clobbering another package’s files — the kind of accident that is invisible until a user’s environment breaks.

Consequences

  • pip install luria is the installation path; the README says so.
  • Releasing is: merge, cut a GitHub release, watch publish.yml. The version lives in pyproject.toml and must be bumped per release — PyPI will refuse a reused version, loudly, which is the right failure.
  • Project metadata (classifiers, URLs, keywords) now renders a real PyPI page; the author is listed by GitHub handle.
  • The template = "luria/template" mapping is one more place the scaffold’s location is spelled; init._template_dir() is the single reader.