From edc1beb69ab2b966d617bd2012b23270af701251 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 25 Mar 2026 09:15:44 -0500 Subject: [PATCH] Use trusted publishing for crates.io (#18709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/publish-crates.yml | 8 +- .github/workflows/release.yml | 1 + .known-crates | 65 ++++ scripts/release.sh | 3 + scripts/setup-crates-io-publish.py | 450 ++++++++++++++++++++++++ scripts/setup-crates-io-publish.py.lock | 73 ++++ 6 files changed, 596 insertions(+), 4 deletions(-) create mode 100644 .known-crates create mode 100644 scripts/setup-crates-io-publish.py create mode 100644 scripts/setup-crates-io-publish.py.lock diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml index 1ce7c915a..11a6e2fb7 100644 --- a/.github/workflows/publish-crates.yml +++ b/.github/workflows/publish-crates.yml @@ -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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce30e9c38..5c87376a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: diff --git a/.known-crates b/.known-crates new file mode 100644 index 000000000..dad9ce826 --- /dev/null +++ b/.known-crates @@ -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 diff --git a/scripts/release.sh b/scripts/release.sh index 579c72f54..1dddce13d 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -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)" diff --git a/scripts/setup-crates-io-publish.py b/scripts/setup-crates-io-publish.py new file mode 100644 index 000000000..380fdfa7d --- /dev/null +++ b/scripts/setup-crates-io-publish.py @@ -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= 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( + "\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() diff --git a/scripts/setup-crates-io-publish.py.lock b/scripts/setup-crates-io-publish.py.lock new file mode 100644 index 000000000..c2c2cdc7d --- /dev/null +++ b/scripts/setup-crates-io-publish.py.lock @@ -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" }, +]