| # /// script | |
| # requires-python = ">=3.12" | |
| # dependencies = [] | |
| # | |
| # [tool.ty.analysis] | |
| # return-type-inference = "always" | |
| # | |
| # [tool.ty.rules] | |
| # truthiness-test-of-none-union = "warn" | |
| # blanket-ignore-comment = "warn" | |
| # missing-type-argument = "warn" | |
| # possibly-unresolved-reference = "warn" | |
| # unsound-return-statement = "warn" | |
| # unsound-yield = "warn" | |
| # unsupported-dynamic-base = "warn" | |
| # division-by-zero = "warn" | |
| # dynamic-function-decorator-return = "warn" | |
| # unsound-assignment = "warn" | |
| # redundant-condition-strict = "warn" | |
| # disjoint-cast = "warn" | |
| # missing-direct-dependency = "warn" | |
| # | |
| # [tool.uv] | |
| # no-build = true | |
| # exclude-newer = "P7D" | |
| # require-build-hashes = true | |
| # preview-features = ["build-dependency-hashes", "missing-exclude-newer-package-lock"] | |
| # /// | |
| # Publish workspace crates to crates.io idempotently. | |
| # | |
| # `cargo publish --workspace` fails if any selected crate version already exists on crates.io. That | |
| # makes release re-runs fail after a previous partial publish. This script queries crates.io first, | |
| # excludes package versions that already exist, then delegates to `cargo publish --workspace` for the | |
| # remaining packages so Cargo still handles package ordering. | |
| # | |
| # Usage: | |
| # | |
| # CARGO_REGISTRY_TOKEN=<tok> python3 scripts/publish-crates.py --no-verify | |
| from __future__ import annotations | |
| import argparse | |
| import dataclasses | |
| import json | |
| import pathlib | |
| import subprocess | |
| import sys | |
| import time | |
| import urllib.error | |
| import urllib.parse | |
| import urllib.request | |
| CRATES_IO_API = "https://crates.io/api/v1" | |
| USER_AGENT = "ruff-crates-io-publish (github.com/astral-sh/ruff)" | |
| REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent | |
| # Short delay between crates.io API requests to avoid burst rate limits. | |
| CRATES_IO_REQUEST_DELAY_SECS = 0.2 | |
| @dataclasses.dataclass(frozen=True) | |
| class Crate: | |
| name: str | |
| version: str | |
| def pretty(self) -> str: | |
| return f"{self.name}@{self.version}" | |
| def get_publishable_crates(cargo: list[str]) -> list[Crate]: | |
| """Return publishable workspace crates.""" | |
| result = subprocess.run( | |
| [*cargo, "metadata", "--format-version", "1", "--no-deps"], | |
| cwd=REPO_ROOT, | |
| 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(Crate(name=package["name"], version=package["version"])) | |
| return crates | |
| def crate_version_url(crate: Crate, api_url: str) -> str: | |
| crate_name = urllib.parse.quote(crate.name, safe="") | |
| crate_version = urllib.parse.quote(crate.version, safe="") | |
| return f"{api_url}/crates/{crate_name}/{crate_version}" | |
| def crate_version_exists(crate: Crate, api_url: str) -> bool: | |
| """Return True if the crate version already exists on crates.io.""" | |
| request = urllib.request.Request( | |
| crate_version_url(crate, api_url), | |
| headers={"User-Agent": USER_AGENT}, | |
| ) | |
| try: | |
| with urllib.request.urlopen(request) as response: | |
| if response.status == 200: | |
| return True | |
| raise RuntimeError( | |
| f"unexpected status {response.status} for {crate.pretty()}" | |
| ) | |
| except urllib.error.HTTPError as exc: | |
| if exc.code == 404: | |
| return False | |
| raise RuntimeError( | |
| f"failed to query {crate.pretty()} from crates.io: HTTP {exc.code}" | |
| ) from exc | |
| except urllib.error.URLError as exc: | |
| raise RuntimeError( | |
| f"failed to query {crate.pretty()} from crates.io: {exc}" | |
| ) from exc | |
| def existing_crate_versions(crates: list[Crate], api_url: str) -> set[str]: | |
| """Return the names of workspace crates whose current version exists on crates.io.""" | |
| existing = set() | |
| for index, crate in enumerate(crates): | |
| if index > 0: | |
| time.sleep(CRATES_IO_REQUEST_DELAY_SECS) | |
| if crate_version_exists(crate, api_url): | |
| print(f"Skipping {crate.pretty()}: already exists on crates.io") | |
| existing.add(crate.name) | |
| return existing | |
| def build_cargo_publish_command( | |
| cargo: list[str], existing: set[str], cargo_publish_args: list[str] | |
| ) -> list[str]: | |
| """Build the `cargo publish` command for all not-yet-published workspace crates.""" | |
| command = [*cargo, "publish", "--workspace"] | |
| for crate_name in sorted(existing): | |
| command.extend(["--exclude", crate_name]) | |
| command.extend(cargo_publish_args) | |
| return command | |
| def publish_workspace( | |
| cargo: list[str], crates: list[Crate], api_url: str, cargo_publish_args: list[str] | |
| ) -> int: | |
| existing = existing_crate_versions(crates, api_url) | |
| missing = [crate for crate in crates if crate.name not in existing] | |
| if not missing: | |
| print("All publishable workspace crate versions already exist on crates.io") | |
| return 0 | |
| print("Publishing workspace crate versions that do not exist on crates.io:") | |
| for crate in missing: | |
| print(f" {crate.pretty()}") | |
| command = build_cargo_publish_command(cargo, existing, cargo_publish_args) | |
| return subprocess.run(command, cwd=REPO_ROOT, check=False).returncode | |
| def parse_args(argv: list[str] | None = None) -> argparse.Namespace: | |
| parser = argparse.ArgumentParser( | |
| description="Publish workspace crates to crates.io idempotently." | |
| ) | |
| parser.add_argument( | |
| "--api-url", | |
| default=CRATES_IO_API, | |
| help=argparse.SUPPRESS, | |
| ) | |
| parser.add_argument( | |
| "--cargo", | |
| default=["cargo"], | |
| nargs="+", | |
| help=argparse.SUPPRESS, | |
| ) | |
| parser.add_argument( | |
| "--dry-run", | |
| action="store_true", | |
| help="Pass --dry-run through to cargo publish.", | |
| ) | |
| parser.add_argument( | |
| "--no-verify", | |
| action="store_true", | |
| help="Pass --no-verify through to cargo publish.", | |
| ) | |
| parser.add_argument( | |
| "cargo_publish_args", | |
| nargs=argparse.REMAINDER, | |
| help=argparse.SUPPRESS, | |
| ) | |
| args = parser.parse_args(argv) | |
| if args.cargo_publish_args[:1] == ["--"]: | |
| args.cargo_publish_args = args.cargo_publish_args[1:] | |
| return args | |
| def main(argv: list[str] | None = None) -> int: | |
| args = parse_args(argv) | |
| cargo_publish_args = [] | |
| if args.dry_run: | |
| cargo_publish_args.append("--dry-run") | |
| if args.no_verify: | |
| cargo_publish_args.append("--no-verify") | |
| cargo_publish_args.extend(args.cargo_publish_args) | |
| crates = get_publishable_crates(args.cargo) | |
| return publish_workspace(args.cargo, crates, args.api_url, cargo_publish_args) | |
| if __name__ == "__main__": | |
| sys.exit(main()) |