|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +Publish all workspace crates to crates.io in dependency order. |
| 4 | +
|
| 5 | +Usage: |
| 6 | + python scripts/releases/crates.py # dry-run (shows what would happen) |
| 7 | + python scripts/releases/crates.py --publish # publish all crates to crates.io |
| 8 | +
|
| 9 | +What it does: |
| 10 | + 1. Reads the version from [workspace.package] in Cargo.toml |
| 11 | + 2. Checks that the version is not already published on crates.io |
| 12 | + 3. Checks that the git tag v{version} exists (run github.py first) |
| 13 | + 4. Checks that cargo login is configured |
| 14 | + 5. Publishes crates in dependency order with sleeps between batches |
| 15 | +
|
| 16 | +Requires: |
| 17 | + - python3 (no external dependencies) |
| 18 | + - cargo on PATH with a valid crates.io token (cargo login) |
| 19 | + - network access to crates.io |
| 20 | + - git tag v{version} must exist (run github.py --push first) |
| 21 | +
|
| 22 | +Publish order (dependency layers): |
| 23 | + Batch 1: auths, auths-crypto, auths-index, auths-policy, auths-telemetry |
| 24 | + Batch 2: auths-verifier, auths-keri |
| 25 | + Batch 3: auths-core |
| 26 | + Batch 4: auths-infra-http |
| 27 | + Batch 5: auths-id |
| 28 | + Batch 6: auths-storage, auths-sdk |
| 29 | + Batch 7: auths-infra-git |
| 30 | + Batch 8: auths-cli |
| 31 | +""" |
| 32 | + |
| 33 | +import json |
| 34 | +import re |
| 35 | +import subprocess |
| 36 | +import sys |
| 37 | +import time |
| 38 | +import urllib.request |
| 39 | +from pathlib import Path |
| 40 | + |
| 41 | +CARGO_TOML = Path(__file__).resolve().parents[2] / "Cargo.toml" |
| 42 | +CRATES_IO_API = "https://crates.io/api/v1/crates" |
| 43 | + |
| 44 | +PUBLISH_BATCHES: list[list[str]] = [ |
| 45 | + ["auths", "auths-crypto", "auths-index", "auths-policy", "auths-telemetry"], |
| 46 | + ["auths-verifier", "auths-keri"], |
| 47 | + ["auths-core"], |
| 48 | + ["auths-infra-http"], |
| 49 | + ["auths-id"], |
| 50 | + ["auths-storage", "auths-sdk"], |
| 51 | + ["auths-infra-git"], |
| 52 | + ["auths-cli"], |
| 53 | +] |
| 54 | + |
| 55 | +SLEEP_BETWEEN_BATCHES = 60 |
| 56 | + |
| 57 | + |
| 58 | +def get_workspace_version() -> str: |
| 59 | + text = CARGO_TOML.read_text() |
| 60 | + match = re.search(r'^\[workspace\.package\].*?^version\s*=\s*"([^"]+)"', text, re.MULTILINE | re.DOTALL) |
| 61 | + if not match: |
| 62 | + in_workspace_package = False |
| 63 | + for line in text.splitlines(): |
| 64 | + stripped = line.strip() |
| 65 | + if stripped == "[workspace.package]": |
| 66 | + in_workspace_package = True |
| 67 | + continue |
| 68 | + if in_workspace_package and stripped.startswith("["): |
| 69 | + break |
| 70 | + if in_workspace_package: |
| 71 | + m = re.match(r'version\s*=\s*"([^"]+)"', stripped) |
| 72 | + if m: |
| 73 | + return m.group(1) |
| 74 | + print("ERROR: Could not find version in [workspace.package] in Cargo.toml", file=sys.stderr) |
| 75 | + sys.exit(1) |
| 76 | + return match.group(1) |
| 77 | + |
| 78 | + |
| 79 | +def get_crate_published_version(crate_name: str) -> str | None: |
| 80 | + url = f"{CRATES_IO_API}/{crate_name}" |
| 81 | + req = urllib.request.Request(url, headers={"User-Agent": "auths-release-script/1.0"}) |
| 82 | + try: |
| 83 | + with urllib.request.urlopen(req, timeout=10) as resp: |
| 84 | + data = json.loads(resp.read()) |
| 85 | + return data["crate"]["max_version"] |
| 86 | + except Exception: |
| 87 | + return None |
| 88 | + |
| 89 | + |
| 90 | +def tag_exists(tag: str) -> bool: |
| 91 | + result = subprocess.run( |
| 92 | + ["git", "tag", "-l", tag], |
| 93 | + capture_output=True, |
| 94 | + text=True, |
| 95 | + cwd=CARGO_TOML.parent, |
| 96 | + ) |
| 97 | + return bool(result.stdout.strip()) |
| 98 | + |
| 99 | + |
| 100 | +def cargo_login_configured() -> bool: |
| 101 | + result = subprocess.run( |
| 102 | + ["cargo", "login", "--help"], |
| 103 | + capture_output=True, |
| 104 | + text=True, |
| 105 | + ) |
| 106 | + if result.returncode != 0: |
| 107 | + return False |
| 108 | + # Try a dry-run publish to check token — just verify cargo config exists |
| 109 | + result = subprocess.run( |
| 110 | + ["cargo", "publish", "-p", "auths", "--dry-run"], |
| 111 | + capture_output=True, |
| 112 | + text=True, |
| 113 | + cwd=CARGO_TOML.parent, |
| 114 | + ) |
| 115 | + if "no token found" in result.stderr.lower() or "no upload token" in result.stderr.lower(): |
| 116 | + return False |
| 117 | + return True |
| 118 | + |
| 119 | + |
| 120 | +def publish_crate(crate_name: str) -> bool: |
| 121 | + print(f" Publishing {crate_name}...", flush=True) |
| 122 | + result = subprocess.run( |
| 123 | + ["cargo", "publish", "-p", crate_name], |
| 124 | + cwd=CARGO_TOML.parent, |
| 125 | + ) |
| 126 | + if result.returncode != 0: |
| 127 | + print(f" ERROR: cargo publish -p {crate_name} failed (exit {result.returncode})", file=sys.stderr) |
| 128 | + return False |
| 129 | + print(f" {crate_name} published.", flush=True) |
| 130 | + return True |
| 131 | + |
| 132 | + |
| 133 | +def main() -> None: |
| 134 | + publish = "--publish" in sys.argv |
| 135 | + |
| 136 | + version = get_workspace_version() |
| 137 | + tag = f"v{version}" |
| 138 | + all_crates = [crate for batch in PUBLISH_BATCHES for crate in batch] |
| 139 | + |
| 140 | + print(f"Workspace version: {version}") |
| 141 | + print(f"Crates to publish: {len(all_crates)}") |
| 142 | + |
| 143 | + # Check that the auths root crate isn't already at this version |
| 144 | + published = get_crate_published_version("auths") |
| 145 | + if published: |
| 146 | + print(f"crates.io version: {published}") |
| 147 | + if published == version: |
| 148 | + print(f"\nERROR: Version {version} is already published on crates.io.", file=sys.stderr) |
| 149 | + print("Bump the version in Cargo.toml before publishing.", file=sys.stderr) |
| 150 | + sys.exit(1) |
| 151 | + else: |
| 152 | + print("crates.io version: (not found or not published yet)") |
| 153 | + |
| 154 | + # Check git tag exists (should run github.py --push first) |
| 155 | + if not tag_exists(tag): |
| 156 | + print(f"\nERROR: Git tag {tag} does not exist.", file=sys.stderr) |
| 157 | + print("Run 'python scripts/releases/github.py --push' first.", file=sys.stderr) |
| 158 | + sys.exit(1) |
| 159 | + print(f"Git tag {tag}: exists") |
| 160 | + |
| 161 | + # Check cargo login |
| 162 | + print("Checking cargo auth...", flush=True) |
| 163 | + if not cargo_login_configured(): |
| 164 | + print("\nERROR: No crates.io token found.", file=sys.stderr) |
| 165 | + print("Run 'cargo login' first.", file=sys.stderr) |
| 166 | + sys.exit(1) |
| 167 | + print("Cargo auth: ok") |
| 168 | + |
| 169 | + # Show publish plan |
| 170 | + print(f"\nPublish plan ({SLEEP_BETWEEN_BATCHES}s sleep between batches):") |
| 171 | + for i, batch in enumerate(PUBLISH_BATCHES, 1): |
| 172 | + print(f" Batch {i}: {', '.join(batch)}") |
| 173 | + |
| 174 | + if not publish: |
| 175 | + print("\nDry run: no crates were published.") |
| 176 | + print("Run with --publish to execute.") |
| 177 | + return |
| 178 | + |
| 179 | + # Publish |
| 180 | + failed: list[str] = [] |
| 181 | + for i, batch in enumerate(PUBLISH_BATCHES, 1): |
| 182 | + print(f"\n--- Batch {i}/{len(PUBLISH_BATCHES)} ---", flush=True) |
| 183 | + for crate_name in batch: |
| 184 | + if not publish_crate(crate_name): |
| 185 | + failed.append(crate_name) |
| 186 | + print(f"\nAborting: {crate_name} failed. Fix the issue and re-run.", file=sys.stderr) |
| 187 | + print(f"Already published crates are fine — cargo publish is idempotent for the same version.", file=sys.stderr) |
| 188 | + sys.exit(1) |
| 189 | + |
| 190 | + if i < len(PUBLISH_BATCHES): |
| 191 | + print(f" Waiting {SLEEP_BETWEEN_BATCHES}s for crates.io index to update...", flush=True) |
| 192 | + time.sleep(SLEEP_BETWEEN_BATCHES) |
| 193 | + |
| 194 | + print(f"\nDone. All {len(all_crates)} crates published at version {version}.") |
| 195 | + print(f" https://crates.io/crates/auths/{version}") |
| 196 | + |
| 197 | + |
| 198 | +if __name__ == "__main__": |
| 199 | + main() |
0 commit comments