mirror of
https://github.com/astral-sh/uv.git
synced 2026-05-06 08:56:53 -04:00
451 lines
15 KiB
Python
451 lines
15 KiB
Python
# 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 = "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(
|
|
"<!-- 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()
|