# 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= 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 = "release.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( "\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()