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())