Skip to content

feat: add fledgling pypi to claim names + hand off trusted publishing - #17

Draft
theoephraim wants to merge 1 commit into
mainfrom
feat/pypi-claim-command
Draft

theoephraim wants to merge 1 commit into
mainfrom
feat/pypi-claim-command

Conversation

@theoephraim

@theoephraim theoephraim commented Sep 17, 2026

Copy link
Copy Markdown
Member

not done yet... but tracking here


Why

PyPI is the odd one out among the registries fledgling handles:

  • No create-on-first-publish problem. A pending publisher can be registered for a project that doesn't exist yet, and the first OIDC publish creates it. No placeholder needed for that.
  • But no API for any of it. Every publisher-management route on PyPI is a session + CSRF protected HTML form. The only machine endpoints (/_/oidc/audience, /_/oidc/mint-token) mint tokens; they don't manage publishers. There's no npm trust equivalent, and nothing like JSR's PATCH …/packages/{pkg}.

So this command automates the half that can be automated — which is also the half that matters. A pending publisher does not reserve the name. Until something is published, anyone can take it, which invalidates the pending publisher.

What it does

fledgling pypi <names…>:

  1. Claim — uploads a minimal placeholder sdist per name. Built in-process (gzipped tar, one PKG-INFO); no python, build, setuptools, or twine. PyPI reads metadata from the multipart form fields, not the archive.
  2. Hand off — prints every field value PyPI's web form wants (owner, repo, workflow, environment — auto-detected from git origin + the fledgling config) and the exact page per package.

Claiming also sidesteps PyPI's 3-pending-publisher cap, since a project that exists is configured on its own settings page instead, with no limit.

Names are passed explicitly rather than discovered from pyproject.toml — claiming is most useful before there's a package to discover, and it keeps the npm-shaped workspace-discovery and config machinery out of it. No new dependencies.

Verification

The generated sdist was validated against warehouse's actual checks, using Python's own libraries rather than a reading of the source:

  • tarfile.is_tarfile, os.path.commonpath resolving to the sdist root, {root}/PKG-INFO member lookup
  • packaging.utils.parse_sdist_filename
  • the PEP 625 expected_filename comparison warehouse performs
  • packaging.metadata.Metadata.from_email

That caught a real bug. The first version put a single member in the tarball — and os.path.commonpath() on one path returns that whole path, so warehouse would have looked for foo-0.0.0/PKG-INFO/PKG-INFO and rejected every upload with a confusing 400. The archive now carries a directory member, as any real sdist does. This would not have surfaced until a name was burned.

Error paths exercised: missing names, invalid names, non-canonical placeholder versions, PEP 503 duplicate collapsing (my-pkg + my_pkg), and an already-taken name.

Not verified

⚠️ The multipart upload has never run. It needs a live PyPI token, and claiming a name is irreversible. Field names and auth are built from warehouse's UploadForm and parse_form_metadata, but they're unexercised.

Rehearse on TestPyPI (separate account and token) before claiming anything real:

PYPI_TOKEN=pypi-… npx fledgling pypi some-throwaway-name --test --yes

Known gap

pypi has no config namespace of its own — it borrows the top-level workflow / environment keys for the checklist, where jsr has fledgling.jsr.*. Fine when one workflow publishes everything, wrong as soon as it doesn't (varlock's file is release.yaml, not the release.yml default). Worth a follow-up adding fledgling.pypi.workflow / .environment with fallback to the npm-level keys.

PyPI is the odd one out. It has no create-on-first-publish problem — a
pending publisher can be registered for a project that doesn't exist yet —
but it also has no API for any of it: every publisher-management route is a
session + CSRF protected HTML form, and the only machine endpoints
(`/_/oidc/audience`, `/_/oidc/mint-token`) mint tokens rather than manage
publishers. There's no `npm trust` equivalent to call.

So `fledgling pypi` automates the half that can be automated, which is also
the half that matters: a pending publisher does *not* reserve the name, so
until something is published anyone can take it and invalidate the pending
publisher. Each name gets a minimal placeholder sdist — built in-process, no
Python toolchain — and then the command prints every value PyPI's web form
wants plus the exact page per package. Claiming also sidesteps PyPI's
3-pending-publisher cap, since a project that exists is configured on its
own settings page.

Names are passed explicitly rather than discovered from pyproject.toml:
claiming is most useful before there's a package to discover, and it keeps
the npm-shaped workspace/config machinery out of it.

Verified the generated sdist against warehouse's actual checks using
Python's own libraries — tarfile/commonpath/PKG-INFO lookup,
packaging.utils.parse_sdist_filename, the PEP 625 expected-filename
comparison, and packaging.metadata.Metadata.from_email. That caught a real
bug: os.path.commonpath() on a single path returns the whole path, so a
one-member tarball made warehouse look for `foo-0.0.0/PKG-INFO/PKG-INFO`
and reject every upload. The archive now carries a directory member, as any
real sdist does.

The multipart upload itself is unexercised — it needs a live token, so
rehearse with `--test` against TestPyPI before claiming a real name.
@github-actions

Copy link
Copy Markdown

bumpy-frog

The changes in this PR will be included in the next version bump.

minor Minor releases

  • fledgling 1.2.1 → 1.3.0

Bump files in this PR

Click here if you want to add another bump file to this PR


This comment is maintained by bumpy.

@theoephraim
theoephraim marked this pull request as draft September 17, 2026 19:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant