-
Notifications
You must be signed in to change notification settings - Fork 120
/
release.py
338 lines (255 loc) · 8.55 KB
/
release.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
from enum import Enum
from os import chdir
from pathlib import Path
from shutil import rmtree
from subprocess import CalledProcessError, run
from sys import exit, stderr
from tempfile import mkdtemp
from typing import Any, Dict, NoReturn, Optional, Sequence
from click import group as commandGroup
from click import option as commandOption
from git import Repo as Repository
from git import TagReference
from git.refs.head import Head
from incremental import Version
class PyPI(Enum):
Test = "testpypi"
Production = "pypi"
def warning(message: str) -> None:
"""
Print a warning.
"""
print(f"WARNING: {message}", file=stderr)
def error(message: str, exitStatus: int) -> NoReturn:
"""
Print an error message and exit with the given status.
"""
print(f"ERROR: {message}", file=stderr)
exit(exitStatus)
def spawn(args: Sequence[str]) -> None:
"""
Spawn a new process with the given arguments, raising L{SystemExit} with
captured output if the exit status is non-zero.
"""
print("Executing command:", " ".join(repr(arg) for arg in args))
try:
run(args, input=b"", capture_output=True, check=True)
except CalledProcessError as e:
error(f"command {e.cmd} failed: {e.stderr}", 1)
def currentVersion() -> Version:
"""
Determine the current version.
"""
# Incremental doesn't have an API to do this, so we are duplicating some
# code from its source tree. Boo.
versionInfo: Dict[str, Any] = {}
versonFile = Path(__file__).parent / "src" / "klein" / "_version.py"
exec(versonFile.read_text(), versionInfo)
version: Version = versionInfo["__version__"]
return version
def fadeToBlack() -> None:
"""
Run black to reformat the source code.
"""
spawn(["pre-commit", "run", "black"])
def incrementVersion(candidate: bool) -> None:
"""
Increment the current release version.
If C{candidate} is C{True}, the new version will be a release candidate;
otherwise it will be a regular release.
"""
# Incremental doesn't have an API to do this, so we have to run a
# subprocess. Boo.
args = ["python", "-m", "incremental.update", "Klein"]
if candidate:
args.append("--rc")
spawn(args)
# Incremental generates code that black wants to reformat.
fadeToBlack()
def releaseBranchName(version: Version) -> str:
"""
Compute the name of the release branch for the given version.
"""
return f"release-{version.major}.{version.minor}"
def releaseBranch(repository: Repository, version: Version) -> Optional[Head]:
"""
Return the release branch corresponding to the given version.
"""
branchName = releaseBranchName(version)
if branchName in repository.heads:
return repository.heads[branchName]
return None
def releaseTagName(version: Version) -> str:
"""
Compute the name of the release tag for the given version.
"""
return version.public()
def createReleaseBranch(repository: Repository, version: Version) -> Head:
"""
Create a new release branch.
"""
branchName = releaseBranchName(version)
if branchName in repository.heads:
error(f'Release branch "{branchName}" already exists.', 1)
print(f'Creating release branch: "{branchName}"')
return repository.create_head(branchName)
def clone(repository: Repository, tag: TagReference) -> Path:
"""
Clone a tagged version from the given repository's origin.
Return the path to the new clone.
"""
path = Path(mkdtemp())
print(f"Cloning repository with tag {tag} at {path}...")
Repository.clone_from(
url=next(repository.remotes.origin.urls),
to_path=str(path),
branch=tag.name,
multi_options=["--depth=1"],
)
return path
def distribute(
repository: Repository, tag: TagReference, test: bool = False
) -> None:
"""
Build a distribution for the project at the given path and upload to PyPI.
"""
src = clone(repository, tag)
if test:
pypi = PyPI.Test
else:
pypi = PyPI.Production
wd = Path.cwd()
try:
chdir(src)
print("Building distribution at:", src)
spawn(["python", "setup.py", "sdist", "bdist_wheel"])
print(f"Uploading distribution to {pypi.value}...")
twineCommand = ["twine", "upload"]
twineCommand.append(f"--repository={pypi.value}")
twineCommand += [str(p) for p in Path("dist").iterdir()]
spawn(twineCommand)
finally:
chdir(wd)
rmtree(str(src))
def startRelease() -> None:
"""
Start a new release:
* Increment the current version to a new release candidate version.
* Create a corresponding branch.
* Switch to the new branch.
"""
repository = Repository()
if repository.head.ref != repository.heads.trunk:
error(
f"working copy is from non-trunk branch: {repository.head.ref}", 1
)
if repository.is_dirty():
warning("working copy is dirty")
version = currentVersion()
if version.release_candidate is not None:
error(f"current version is already a release candidate: {version}", 1)
incrementVersion(candidate=True)
version = currentVersion()
print(f"New release candidate version: {version.public()}")
branch = createReleaseBranch(repository, version)
branch.checkout()
print("Next steps (to be done manually):")
print(" • Commit version changes to the new release branch:", branch)
print(" • Push the release branch to GitHub")
print(" • Open a pull request on GitHub from the release branch")
def bumpRelease() -> None:
"""
Increment the release candidate version.
"""
repository = Repository()
if repository.is_dirty():
warning("working copy is dirty")
version = currentVersion()
if version.release_candidate is None:
error(f"current version is not a release candidate: {version}", 1)
branch = releaseBranch(repository, version)
if repository.head.ref != branch:
error(
f'working copy is on branch "{repository.head.ref}", '
f'not release branch "{branch}"',
1,
)
incrementVersion(candidate=True)
version = currentVersion()
print("New release candidate version:", version.public())
def publishRelease(final: bool, test: bool = False) -> None:
"""
Publish the current version.
"""
repository = Repository()
if repository.is_dirty():
error("working copy is dirty", 1)
version = currentVersion()
if version.release_candidate is None:
error(f"current version is not a release candidate: {version}", 1)
branch = releaseBranch(repository, version)
if repository.head.ref != branch:
error(
f'working copy is on branch "{repository.head.ref}", '
f'not release branch "{branch}"',
1,
)
incrementVersion(candidate=not final)
version = currentVersion()
repository.index.add("src/klein")
repository.index.commit(f"Update version to {version}")
tagName = releaseTagName(version)
if tagName in repository.tags:
tag = repository.tags[tagName]
message = f"Release tag already exists: {tagName}"
if tag.commit != repository.head.ref.commit:
error(message, 1)
else:
print(message)
else:
print("Creating release tag:", tagName)
tag = repository.create_tag(
tagName, ref=branch, message=f"Tag release {version.public()}"
)
print("Pushing tag to origin:", tag)
repository.remotes.origin.push(refspec=tag.path)
print("Pushing branch to origin:", branch)
repository.remotes.origin.push()
distribute(repository, tag, test=test)
@commandGroup()
def main() -> None:
"""
Software release tool for Klein.
"""
@main.command()
def start() -> None:
"""
Begin a new release process.
"""
startRelease()
@main.command()
def bump() -> None:
"""
Increase the version number for an in-progress release candidate.
"""
bumpRelease()
@main.command()
@commandOption(
"--test/--production",
help="Use test (or production) PyPI server",
default=False,
)
@commandOption(
"--final/--candidate",
help="Publish a final (or candidate) release",
default=False,
)
def publish(final: bool, test: bool) -> None:
"""
Publish the current version of the software to PyPI.
"""
publishRelease(final=final, test=test)
if __name__ == "__main__":
main()