sh1pt browser: register trusted publishers on PyPI and RubyGems - #1009
Merged
Merged
Conversation
Publishing a new package from CI with no long-lived token means registering
a trusted publisher, and neither registry exposes that to an API. PyPI's
upload API publishes packages rather than account settings; RubyGems keeps
publishers under the profile and `gem` has no command for them. Both are
console-only, which is what this package is for.
The form worth automating is the *pending* publisher. It names a package
that does not exist yet and creates it on the first publish, so a brand-new
project ships without a token ever being minted. Doing that by hand is four
near-identical forms across two sites for a single two-language release.
sh1pt browser pypi-trusted-publisher add-pending \
--package profullstack-x402-gateway --owner profullstack \
--repo x402-ports --workflow release-python.yml
Both actions are idempotent: add-pending reads the existing list first and
reports alreadyPresent instead of creating a duplicate, so a fleet script can
call it unconditionally.
Selectors are taken from each project's own source rather than guessed.
PyPI's form is #pending-github-publisher-form with inputs project_name,
owner, repository, workflow_filename and environment. RubyGems renders a
Rails nested form, so its long prefixed names are matched on their suffix,
which survives the wrapper being renamed.
Two details that decide whether this works at all:
- Both registries require two-factor on any account that can publish, so
totp.ts implements RFC 6238 with no dependency and is checked against the
RFC's own vectors. With a seed in PYPI_TOTP_SECRET or RUBYGEMS_TOTP_SECRET
a run is unattended; without one it parks on session.ask() and waits for
the code, which is strictly better than failing. twoFactorCode also waits
out a code with under three seconds left, because a console that takes a
moment to submit would reject it.
- The environment field is always written, empty included. Both registries
match it exactly, so a value left against a workflow that declares no
environment rejects every publish, and a stale value must not survive an
edit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKuF2jCbRj3GZTQVtwhi5m
vu1nz Security Review0 finding(s) in PR #? No security issues found. |
ThreatCrush Security Scan49 finding(s) HIGH/CRITICAL: 1 | MEDIUM: 14 | LOW: 34
Snippets are redacted; ThreatCrush never prints matched credential material. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Publishing a new package from CI without a long-lived token means registering a trusted publisher, and neither PyPI nor RubyGems exposes that to an API. PyPI's upload API publishes packages rather than account settings; RubyGems keeps publishers under the profile and
gemhas no command for them. Both are console-only, which is exactly whatpackages/automation/browseris for.The form worth automating is the pending publisher: it names a package that does not exist yet and creates it on the first publish, so a brand-new project ships without a token ever being minted. Doing it by hand is four near-identical forms across two sites for one two-language release.
Both actions are idempotent.
add-pendingreads the existing list first and returns{ "added": false, "alreadyPresent": true }rather than creating a duplicate, so a fleet script can call it unconditionally.Selectors come from each project's source, not from guessing
#pending-github-publisher-form, with inputsproject_name,owner,repository,workflow_filename,environment, read out of warehouse's own template.Each field is still looked up through a list of candidates, the way
clickFirstalready works here, because a form that has been renamed once will be renamed again.Two details that decide whether it works at all
Two-factor. Both registries require it on any account that can publish, so an unattended run has to produce a code.
totp.tsimplements RFC 6238 with no dependency and is checked against the RFC's own published vectors. With a seed inPYPI_TOTP_SECRETorRUBYGEMS_TOTP_SECRETthe run is unattended; without one it parks on the existingsession.ask()file handoff and waits, which is strictly better than failing.twoFactorCodealso waits out a code with under three seconds left on it, because a console that takes a moment to submit would reject it and the failure would look like nothing at all.The environment field is always written, empty included. Both registries match it exactly, so a value set against a workflow that declares no environment rejects every publish, and a stale value must not survive an edit. This is the single most common way these forms are filled in wrong.
Credentials
From the environment, never flags, so nothing lands in shell history:
PYPI_USERNAME,PYPI_PASSWORD,PYPI_TOTP_SECRET,RUBYGEMS_USERNAME,RUBYGEMS_PASSWORD,RUBYGEMS_TOTP_SECRET. Each recipe gets its own persistent profile, so signing in to one registry never signs the other out.A security key instead of an authenticator cannot be driven headlessly at all. The PyPI recipe detects that and says so with a screenshot, rather than hanging.
Tests
27 new tests, all pure logic and none needing a browser: the RFC 6238 vectors, base32 tolerance for the spacing sites print, code padding, the expiry guard, the idempotence matcher against realistic row text, registry consistency including that no two recipes share a profile, and flag parsing.
pnpm vitest run packages/automation/browser packages/cliis green at 335 tests, and the package typechecks.🤖 Generated with Claude Code
https://claude.ai/code/session_01TKuF2jCbRj3GZTQVtwhi5m