1
+#!/usr/bin/env python3
2
+"""Ask crates.io whether a version of a crate is already published.
3
+
4
+Exits 0 if it is, 1 if it is not, and 2 if the question could not be answered.
5
+Release workflows use the exit code to decide whether to skip a publish, and
6
+again afterwards to assert the publish actually landed.
7
+
8
+This exists because the obvious answer is wrong. `cargo info onde@1.2.4` run
9
+from inside the onde workspace resolves `onde` against the local package, not
10
+the registry, so it succeeds for whatever version the working tree happens to
11
+carry. The release workflow used that as its idempotency guard, concluded every
12
+version was already published, skipped the upload, and exited green: 1.2.3 and
13
+1.2.4 were tagged and never reached crates.io. Query the index directly and
14
+keep cargo out of it.
15
+
16
+Usage:
17
+ crates-io-published.py 1.2.5 # defaults to the `onde` crate
18
+ crates-io-published.py 1.2.5 --crate x
19
+ crates-io-published.py 1.2.5 --wait # retry until it appears
20
+"""
21
+
22
+import argparse
23
+import json
24
+import sys
25
+import time
26
+import urllib.error
27
+import urllib.request
28
+
29
+INDEX = "https://index.crates.io"
30
+USER_AGENT = "onde-release (https://github.com/ondeinference/onde)"
31
+
32
+
33
+def index_path(crate):
34
+ """The sparse index shards by name length, then by leading characters."""
35
+ name = crate.lower()
36
+ if len(name) == 1:
37
+ return "1/{}".format(name)
38
+ if len(name) == 2:
39
+ return "2/{}".format(name)
40
+ if len(name) == 3:
41
+ return "3/{}/{}".format(name[0], name)
42
+ return "{}/{}/{}".format(name[0:2], name[2:4], name)
43
+
44
+
45
+def published_versions(crate):
46
+ url = "{}/{}".format(INDEX, index_path(crate))
47
+ request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
48
+ try:
49
+ with urllib.request.urlopen(request, timeout=30) as response:
50
+ body = response.read().decode("utf-8")
51
+ except urllib.error.HTTPError as error:
52
+ if error.code == 404:
53
+ return []
54
+ raise
55
+ versions = []
56
+ for line in body.splitlines():
57
+ line = line.strip()
58
+ if line:
59
+ versions.append(json.loads(line)["vers"])
60
+ return versions
61
+
62
+
63
+def main():
64
+ parser = argparse.ArgumentParser(description=__doc__)
65
+ parser.add_argument("version")
66
+ parser.add_argument("--crate", default="onde")
67
+ parser.add_argument(
68
+ "--wait",
69
+ action="store_true",
70
+ help="retry until the version appears; the index is a CDN and lags a publish",
71
+ )
72
+ parser.add_argument("--attempts", type=int, default=10)
73
+ parser.add_argument("--interval", type=int, default=15)
74
+ args = parser.parse_args()
75
+
76
+ attempts = args.attempts if args.wait else 1
77
+ for attempt in range(1, attempts + 1):
78
+ try:
79
+ versions = published_versions(args.crate)
80
+ except (urllib.error.URLError, OSError, ValueError) as error:
81
+ print("could not reach the crates.io index: {}".format(error), file=sys.stderr)
82
+ return 2
83
+ if args.version in versions:
84
+ print("{} {} is on crates.io".format(args.crate, args.version))
85
+ return 0
86
+ if attempt < attempts:
87
+ print(
88
+ "attempt {}: {} {} not visible yet, waiting {}s".format(
89
+ attempt, args.crate, args.version, args.interval
90
+ )
91
+ )
92
+ time.sleep(args.interval)
93
+
94
+ print("{} {} is not on crates.io".format(args.crate, args.version))
95
+ return 1
96
+
97
+
98
+if __name__ == "__main__":
99
+ sys.exit(main())