Skip to content

sh1pt browser: register trusted publishers on PyPI and RubyGems - #1009

Merged
ralyodio merged 1 commit into
masterfrom
feat/registry-trusted-publishers
Sep 6, 2026
Merged

ralyodio merged 1 commit into
masterfrom
feat/registry-trusted-publishers

Conversation

@ralyodio

@ralyodio ralyodio commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

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 gem has no command for them. Both are console-only, which is exactly what packages/automation/browser 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 it by hand is four near-identical forms across two sites for one two-language release.

sh1pt browser pypi-trusted-publisher add-pending \
  --package profullstack-x402-gateway --owner profullstack \
  --repo x402-ports --workflow release-python.yml

sh1pt browser rubygems-trusted-publisher add-pending \
  --package x402-gateway --owner profullstack \
  --repo x402-ports --workflow release-ruby.yml

sh1pt browser pypi-trusted-publisher list

Both actions are idempotent. add-pending reads 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

  • PyPI's pending form is #pending-github-publisher-form, with inputs project_name, owner, repository, workflow_filename, environment, read out of warehouse's own template.
  • RubyGems renders a Rails nested form, so its real input names are long and prefixed. Fields are matched on their suffix, which survives the wrapper being renamed and does not depend on how the nesting is spelled today.

Each field is still looked up through a list of candidates, the way clickFirst already 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.ts implements RFC 6238 with no dependency and is checked against the RFC's own published vectors. With a seed in PYPI_TOTP_SECRET or RUBYGEMS_TOTP_SECRET the run is unattended; without one it parks on the existing session.ask() file handoff and waits, which is strictly better than failing. twoFactorCode also 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/cli is green at 335 tests, and the package typechecks.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TKuF2jCbRj3GZTQVtwhi5m

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
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

49 finding(s)

HIGH/CRITICAL: 1 | MEDIUM: 14 | LOW: 34

Severity Rule Location
HIGH js-host-header-trust packages/bots/wechat/src/index.ts:405
MEDIUM redos-nested-quantifier packages/actions-fleet-core/src/action-pack/schema.ts:3
MEDIUM redos-nested-quantifier packages/core/src/setup-helpers.ts:583
MEDIUM redos-nested-quantifier packages/policy/src/rules/bundle-id.ts:3
MEDIUM sql-string-concatenation packages/targets/deploy-wordpress/src/index.ts:154
MEDIUM redos-nested-quantifier packages/targets/desktop-linux/src/index.ts:19
MEDIUM redos-nested-quantifier packages/targets/desktop-mac/src/index.ts:15
MEDIUM redos-nested-quantifier packages/targets/desktop-steamos/src/index.ts:28
MEDIUM redos-nested-quantifier packages/targets/mobile-android/src/index.ts:9
MEDIUM redos-nested-quantifier packages/targets/mobile-ios/src/index.ts:11
MEDIUM redos-nested-quantifier packages/targets/tv-androidtv/src/index.ts:14
MEDIUM redos-nested-quantifier packages/targets/tv-firetv/src/index.ts:13
MEDIUM redos-nested-quantifier packages/targets/tv-tvos/src/index.ts:14
MEDIUM redos-nested-quantifier packages/targets/tv-webos/src/index.ts:26
MEDIUM js-unescaped-html-sink sites/sh1pt.com/app/blog/[slug]/page.tsx:76
LOW secret-generic-credential packages/affiliates/skimlinks/src/index.test.ts:25
LOW secret-generic-credential packages/affiliates/skimlinks/src/index.test.ts:71
LOW secret-generic-api-key packages/affiliates/sovrn/src/index.ts:28
LOW secret-generic-credential packages/agent-providers/opencode/src/__tests__/opencode.test.ts:99
LOW js-nosql-injection packages/ai/amazon-bedrock/src/index.test.ts:121
LOW secret-generic-credential packages/ai/amazon-bedrock/src/index.ts:9
LOW secret-generic-credential packages/ai/amazon-bedrock/src/index.ts:10
LOW secret-generic-credential packages/ai/amazon-bedrock/src/index.ts:11
LOW secret-generic-credential packages/bridges/matrix/src/index.ts:58
LOW secret-generic-credential packages/bridges/matrix/src/index.ts:59
LOW secret-generic-credential packages/bridges/slack/src/index.test.ts:259
LOW secret-generic-credential packages/captcha/captchasolver/src/index.ts:34
LOW secret-generic-credential packages/cli/src/commands/secrets.ts:189
LOW secret-generic-credential packages/cloud/linode/src/index.ts:15
LOW secret-generic-credential packages/observability/sentry/src/index.ts:15
LOW secret-generic-credential packages/outreach/producthunt/src/index.ts:103
LOW secret-generic-credential packages/promo/posthog/src/index.ts:23
LOW secret-generic-credential packages/scanners/threatcrush/test/scan-output.txt:35
LOW secret-generic-credential packages/scanners/threatcrush/test/scan-output.txt:40
LOW secret-database-url packages/scanners/threatcrush/test/scan-output.txt:54
LOW secret-generic-credential packages/security/snyk/src/index.ts:26
LOW secret-generic-credential packages/social/hashnode/src/index.ts:4
LOW secret-generic-credential packages/social/linkedin/src/index.ts:3
LOW secret-generic-credential packages/social/linkedin/src/index.ts:4
LOW secret-generic-credential packages/social/medium/src/index.ts:4
LOW secret-generic-credential packages/social/snapchat/src/index.ts:5
LOW secret-generic-credential packages/social/tiktok/src/index.ts:5
LOW secret-generic-credential packages/targets/plugin-vscode/src/index.test.ts:115
LOW secret-generic-credential packages/targets/registry-ans/src/index.test.ts:79
LOW secret-generic-credential packages/targets/registry-ans/src/index.ts:49
LOW secret-generic-credential packages/targets/sdk-pypi/src/index.test.ts:49
LOW secret-generic-credential packages/vcs/gitlab/src/index.test.ts:96
LOW secret-generic-credential sites/sh1pt.com/supabase/config.toml:303
LOW secret-generic-credential sites/sh1pt.com/supabase/config.toml:335

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit ec1ce53 into master Sep 6, 2026
8 checks passed
@ralyodio
ralyodio deleted the feat/registry-trusted-publishers branch September 6, 2026 05:05
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