Use trusted publishing for crates.io (#18709)

Moves from a crates.io API key to trusted publishing.

Setup of trusted publishing is automated via a script which creates the
trust relationship and disables publish by API key. The main breakage
here is that now, when we add a new crate, a release will fail. The
script is invoked during `release.sh` to catch this case and supports
creating a stub crate so the release can subsequently succeed — but this
will require the release author to have a local crates.io API key with
permissions to create projects and configure publishing. I tested this
script a few times end-to-end, but would not be surprised if it bites us
in the future.
This commit is contained in:
Zanie Blue
2026-03-25 09:15:44 -05:00
committed by GitHub
parent 8cdb2b087a
commit edc1beb69a
6 changed files with 596 additions and 4 deletions
+4 -4
View File
@@ -20,15 +20,15 @@ jobs:
deployment: false
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# TODO(zanieb): Switch to trusted publishing once published
# - uses: rust-lang/crates-io-auth-action@v1
# id: auth
- uses: rust-lang/crates-io-auth-action@bbd81622f20ce9e2dd9622e3218b975523e45bbe # v1.0.4
id: auth
- name: Publish workspace crates
# Note `--no-verify` is safe because we do a publish dry-run elsewhere in CI
run: cargo publish --workspace --no-verify
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_TOKEN }}
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
+1
View File
@@ -273,6 +273,7 @@ jobs:
# publish jobs get escalated permissions
permissions:
"contents": "read"
"id-token": "write"
# Create a GitHub Release while uploading all files to it
announce:
+65
View File
@@ -0,0 +1,65 @@
# GENERATED-BY scripts/setup-crates-io-publish.py
uv
uv-audit
uv-auth
uv-bin-install
uv-build
uv-build-backend
uv-build-frontend
uv-cache
uv-cache-info
uv-cache-key
uv-cli
uv-client
uv-configuration
uv-console
uv-dirs
uv-dispatch
uv-distribution
uv-distribution-filename
uv-distribution-types
uv-extract
uv-flags
uv-fs
uv-git
uv-git-types
uv-globfilter
uv-install-wheel
uv-installer
uv-keyring
uv-logging
uv-macros
uv-metadata
uv-normalize
uv-once-map
uv-options-metadata
uv-pep440
uv-pep508
uv-performance-memory-allocator
uv-platform
uv-platform-tags
uv-preview
uv-publish
uv-pypi-types
uv-python
uv-redacted
uv-requirements
uv-requirements-txt
uv-resolver
uv-scripts
uv-settings
uv-shell
uv-small-str
uv-state
uv-static
uv-test
uv-tool
uv-torch
uv-trampoline-builder
uv-types
uv-unix
uv-version
uv-virtualenv
uv-warnings
uv-windows
uv-workspace
+3
View File
@@ -27,6 +27,9 @@ uv lock
echo "Generating JSON schema..."
cargo dev generate-json-schema
echo "Checking crates.io publish setup..."
uv run "$project_root/scripts/setup-crates-io-publish.py" --quiet
echo "Creating release branch..."
git checkout -b "release/$(uv version --short)"
git commit -am "Bump version to $(uv version --short)"
+450
View File
@@ -0,0 +1,450 @@
# Ensure workspace crates are ready for trusted publishing on crates.io.
#
# This script performs four steps for each candidate crate:
#
# 1. Publish a placeholder crate if the crate doesn't yet exist on crates.io.
# 2. Remove trusted publisher configs that don't match our desired config.
# 3. Ensure exactly one desired trusted publishing config exists.
# 4. Enable `trustpub_only` ("Require trusted publishing for all new versions").
#
# It authenticates with `CARGO_REGISTRY_TOKEN`, which must have the `publish-new` and
# `trusted-publishing` scopes.
#
# Crates tracked in `.known-crates` are assumed to be configured and are skipped unless `--force` is
# used.
#
# Usage:
#
# CARGO_REGISTRY_TOKEN=<tok> uv run scripts/setup-crates-io-publish.py [--dry-run] [--force] [--quiet]
# /// script
# requires-python = ">=3.13"
# dependencies = ["httpx"]
# ///
from __future__ import annotations
import argparse
import json
import os
import pathlib
import subprocess
import sys
import tempfile
import time
import tomllib
import httpx
CRATES_IO_API = "https://crates.io/api/v1"
USER_AGENT = "uv-crates-io-publish-setup (github.com/astral-sh/uv)"
REPOSITORY_OWNER = "astral-sh"
REPOSITORY_NAME = "uv"
WORKFLOW_FILENAME = "publish-crates.yml"
ENVIRONMENT = "release"
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
WORKSPACE_MANIFEST_PATH = REPO_ROOT / "Cargo.toml"
KNOWN_CRATES_PATH = REPO_ROOT / ".known-crates"
KNOWN_CRATES_HEADER = "# GENERATED-BY scripts/setup-crates-io-publish.py\n"
PLACEHOLDER_VERSION = "0.0.0"
# Delay between `cargo publish` calls to respect crates.io rate limits.
PUBLISH_DELAY_SECS = 15
def get_publishable_crates() -> list[dict[str, str]]:
"""Return publishable workspace crates as ``[{"name": …, "version": …}]``."""
result = subprocess.run(
["cargo", "metadata", "--format-version", "1", "--no-deps"],
capture_output=True,
text=True,
check=True,
)
metadata = json.loads(result.stdout)
workspace_member_ids = set(metadata["workspace_members"])
crates = []
for package in metadata["packages"]:
if package["id"] not in workspace_member_ids:
continue
# ``publish = false`` is represented as an empty list in cargo metadata.
if package.get("publish") == []:
continue
crates.append({"name": package["name"], "version": package["version"]})
return sorted(crates, key=lambda c: c["name"])
def load_known_crates() -> set[str]:
"""Load crate names from ``.known-crates``."""
if not KNOWN_CRATES_PATH.exists():
return set()
known = set()
for line in KNOWN_CRATES_PATH.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
known.add(line)
return known
def save_known_crates(crates: set[str]) -> None:
"""Persist the sorted set of fully-configured crate names."""
lines = [KNOWN_CRATES_HEADER]
for name in sorted(crates):
lines.append(f"{name}\n")
KNOWN_CRATES_PATH.write_text("".join(lines))
def get_crate_metadata(
client: httpx.Client, crate_name: str
) -> dict[str, object] | None:
"""Get crate metadata from the public crates.io API."""
response = client.get(f"{CRATES_IO_API}/crates/{crate_name}")
if response.status_code == 404:
return None
response.raise_for_status()
return response.json()
def load_workspace_package_metadata() -> dict[str, object]:
"""Load shared package metadata from the workspace manifest."""
manifest = tomllib.loads(WORKSPACE_MANIFEST_PATH.read_text())
workspace_package = manifest.get("workspace", {}).get("package")
if not isinstance(workspace_package, dict):
raise RuntimeError("workspace.package is missing from Cargo.toml")
return workspace_package
def publish_placeholder_crate(
crate_name: str,
workspace_package: dict[str, object],
) -> bool:
"""Publish a generated placeholder crate to reserve a new crates.io name."""
with tempfile.TemporaryDirectory(prefix=f"{crate_name}-placeholder-") as temp_dir:
temp_path = pathlib.Path(temp_dir)
src_dir = temp_path / "src"
src_dir.mkdir(parents=True)
authors = workspace_package.get("authors", [])
if not isinstance(authors, list):
raise RuntimeError("workspace.package.authors must be a list")
edition = workspace_package.get("edition")
rust_version = workspace_package.get("rust-version")
homepage = workspace_package.get("homepage")
repository = workspace_package.get("repository")
license_expression = workspace_package.get("license")
description = (
"This is a placeholder release for an internal component crate of uv"
)
manifest = (
"[package]\n"
f"name = {json.dumps(crate_name)}\n"
f"version = {json.dumps(PLACEHOLDER_VERSION)}\n"
f"edition = {json.dumps(edition)}\n"
f"rust-version = {json.dumps(rust_version)}\n"
f"authors = {json.dumps(authors)}\n"
f"license = {json.dumps(license_expression)}\n"
f"homepage = {json.dumps(homepage)}\n"
f"repository = {json.dumps(repository)}\n"
f"description = {json.dumps(description)}\n"
'readme = "README.md"\n'
)
(temp_path / "Cargo.toml").write_text(manifest)
(temp_path / "README.md").write_text(
"<!-- This file is generated. DO NOT EDIT -->\n\n"
f"# {crate_name}\n\n"
f"This crate is an internal component of [uv](https://crates.io/crates/uv). "
f"This placeholder version ({PLACEHOLDER_VERSION}) only exists to reserve the "
"crate name and enable trusted publishing for future releases.\n"
)
(src_dir / "lib.rs").write_text(
"//! Placeholder crate published to reserve the name on crates.io.\n\n"
"/// Marker type for the placeholder release.\n"
"pub struct Placeholder;\n"
)
result = subprocess.run(
[
"cargo",
"publish",
"--manifest-path",
str(temp_path / "Cargo.toml"),
"--no-verify",
],
capture_output=True,
text=True,
)
if result.returncode != 0:
print(result.stderr, file=sys.stderr, end="")
return False
return True
def create_trusted_publisher(client: httpx.Client, crate_name: str) -> None:
"""Create a Trusted Publishing GitHub config for *crate_name*."""
response = client.post(
f"{CRATES_IO_API}/trusted_publishing/github_configs",
json={
"github_config": {
"crate": crate_name,
"repository_owner": REPOSITORY_OWNER,
"repository_name": REPOSITORY_NAME,
"workflow_filename": WORKFLOW_FILENAME,
"environment": ENVIRONMENT,
}
},
)
response.raise_for_status()
def list_trusted_publishers(
client: httpx.Client, crate_name: str
) -> list[dict[str, object]]:
"""List Trusted Publishing GitHub configs for *crate_name*."""
response = client.get(
f"{CRATES_IO_API}/trusted_publishing/github_configs",
params={"crate": crate_name},
)
response.raise_for_status()
payload = response.json()
return payload.get("github_configs", [])
def delete_trusted_publisher(client: httpx.Client, config_id: int) -> None:
"""Delete a Trusted Publishing GitHub config by ID."""
response = client.delete(
f"{CRATES_IO_API}/trusted_publishing/github_configs/{config_id}"
)
response.raise_for_status()
def set_trustpub_only(client: httpx.Client, crate_name: str, enabled: bool) -> None:
"""Enable or disable `trustpub_only` for a crate."""
response = client.patch(
f"{CRATES_IO_API}/crates/{crate_name}",
json={"crate": {"trustpub_only": enabled}},
)
response.raise_for_status()
def is_desired_config(config: dict[str, object]) -> bool:
"""Return True if *config* matches the workflow this repo uses."""
return (
config.get("repository_owner") == REPOSITORY_OWNER
and config.get("repository_name") == REPOSITORY_NAME
and config.get("workflow_filename") == WORKFLOW_FILENAME
and config.get("environment") == ENVIRONMENT
)
def handle_trusted_publisher_error(exc: httpx.HTTPStatusError) -> None:
"""Print a helpful hint for common trusted-publishing API errors, then exit."""
print(
f"error {exc.response.status_code}: {exc.response.text}",
file=sys.stderr,
)
if exc.response.status_code == 401:
if "GitHub session has expired" in exc.response.text:
print(
"\nhint: your crates.io account's linked GitHub OAuth"
" token is stale.\nLog out and back in at"
" https://crates.io to refresh it, then re-run.",
file=sys.stderr,
)
else:
print(
"\nhint: your CARGO_REGISTRY_TOKEN may be invalid or revoked.",
file=sys.stderr,
)
elif exc.response.status_code == 403:
print(
"\nhint: your token may lack the trusted-publishing scope,"
" or you are not an owner of this crate.",
file=sys.stderr,
)
sys.exit(1)
def main() -> None:
parser = argparse.ArgumentParser(
description="Ensure workspace crates are ready for trusted publishing on crates.io."
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be done without making changes",
)
parser.add_argument(
"--force", action="store_true", help="Re-check crates already in .known-crates"
)
parser.add_argument(
"--quiet", "-q", action="store_true", help="Suppress informational output"
)
args = parser.parse_args()
dry_run = args.dry_run
force = args.force
quiet = args.quiet
workspace_package = load_workspace_package_metadata()
crates = get_publishable_crates()
known = set() if force else load_known_crates()
candidates = [c for c in crates if c["name"] not in known]
if not candidates:
if not quiet:
print(
f"All {len(crates)} publishable crates are in .known-crates — nothing to do."
)
return
token = os.environ.get("CARGO_REGISTRY_TOKEN", "")
if not token:
print(
"error: CARGO_REGISTRY_TOKEN is required (with `publish-new`"
" and `trusted-publishing` scopes)",
file=sys.stderr,
)
sys.exit(1)
client = httpx.Client(headers={"User-Agent": USER_AGENT}, timeout=30)
auth_client = httpx.Client(
headers={"Authorization": token, "User-Agent": USER_AGENT},
timeout=30,
)
s = "" if len(candidates) == 1 else "s"
if not quiet:
print(f"Checking {len(candidates)} crate{s}")
published_any = False
for crate in candidates:
name = crate["name"]
version = crate["version"]
metadata = get_crate_metadata(client, name)
exists = metadata is not None
trustpub_only_enabled = False
if exists:
crate_payload = metadata.get("crate")
if isinstance(crate_payload, dict):
trustpub_only_enabled = bool(crate_payload.get("trustpub_only", False))
configs: list[dict[str, object]] = []
if exists:
try:
configs = list_trusted_publishers(auth_client, name)
except httpx.HTTPStatusError as exc:
print(f"{name}: failed to list trusted publishers", file=sys.stderr)
handle_trusted_publisher_error(exc)
desired_configs = [config for config in configs if is_desired_config(config)]
extra_desired_configs = desired_configs[1:]
non_matching_configs = [
config for config in configs if not is_desired_config(config)
]
configs_to_delete = non_matching_configs + extra_desired_configs
needs_initial_publish = not exists
needs_add_publisher = not desired_configs
needs_trustpub_only = not trustpub_only_enabled
if dry_run:
actions: list[str] = []
if needs_initial_publish:
actions.append("publish placeholder")
if configs_to_delete:
count = len(configs_to_delete)
noun = "publisher" if count == 1 else "publishers"
actions.append(f"remove {count} {noun}")
if needs_add_publisher:
actions.append("add trusted publisher")
if needs_trustpub_only:
actions.append("enable trustpub_only")
if actions:
print(f"{name}: would {' and '.join(actions)}")
elif not quiet:
print(f"{name}: already configured")
continue
if needs_initial_publish:
if version == PLACEHOLDER_VERSION:
print(
f"{name}: workspace version matches placeholder version {PLACEHOLDER_VERSION}; "
"bump the real crate version before running this script",
file=sys.stderr,
)
sys.exit(1)
if published_any:
print(f"waiting {PUBLISH_DELAY_SECS}s for rate limit")
time.sleep(PUBLISH_DELAY_SECS)
print(f"{name}: publishing placeholder")
if not publish_placeholder_crate(name, workspace_package):
print(f"{name}: placeholder publish failed", file=sys.stderr)
sys.exit(1)
published_any = True
if configs_to_delete:
deleted = 0
for config in configs_to_delete:
config_id = config.get("id")
if not isinstance(config_id, int):
print(
f"{name}: unexpected trusted publisher payload: missing `id`",
file=sys.stderr,
)
sys.exit(1)
try:
delete_trusted_publisher(auth_client, config_id)
except httpx.HTTPStatusError as exc:
print(
f"{name}: failed to delete trusted publisher {config_id}",
file=sys.stderr,
)
handle_trusted_publisher_error(exc)
deleted += 1
noun = "publisher" if deleted == 1 else "publishers"
print(f"{name}: removed {deleted} {noun}")
if needs_add_publisher:
print(f"{name}: registering trusted publisher")
try:
create_trusted_publisher(auth_client, name)
except httpx.HTTPStatusError as exc:
handle_trusted_publisher_error(exc)
if needs_trustpub_only:
print(f"{name}: enabling trustpub_only")
try:
set_trustpub_only(auth_client, name, enabled=True)
except httpx.HTTPStatusError as exc:
handle_trusted_publisher_error(exc)
if (
not configs_to_delete
and not needs_add_publisher
and not needs_trustpub_only
and not quiet
):
print(f"{name}: already configured")
known.add(name)
if not dry_run:
save_known_crates(known)
if __name__ == "__main__":
main()
+73
View File
@@ -0,0 +1,73 @@
version = 1
revision = 3
requires-python = ">=3.13"
[manifest]
requirements = [{ name = "httpx" }]
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]