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