| # 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 --script 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 = "ruff-crates-io-publish-setup (github.com/astral-sh/ruff)" |
| |
| REPOSITORY_OWNER = "astral-sh" |
| REPOSITORY_NAME = "ruff" |
| 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 Ruff" |
| ) |
| |
| 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 [Ruff](https://crates.io/crates/ruff). " |
| 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: |
| crate_names = ", ".join(crate["name"] for crate in candidates) |
| print( |
| f"Crates requiring crates.io publish setup: {crate_names}", |
| file=sys.stderr, |
| ) |
| print( |
| "error: CARGO_REGISTRY_TOKEN is required (with `publish-new`" |
| " and `trusted-publishing` scopes)", |
| file=sys.stderr, |
| ) |
| print( |
| "\nPlease ask a crates.io owner to bootstrap releases which add a new crate.", |
| 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() |